File size: 22,056 Bytes
68b5d22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb99636
 
d29696a
 
 
68b5d22
cb99636
68b5d22
cb99636
 
68b5d22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d29696a
 
68b5d22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d29696a
 
 
 
 
 
 
68b5d22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb99636
68b5d22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb99636
68b5d22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb99636
68b5d22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb99636
68b5d22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb99636
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
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
"""SemBridge — unified speech & singing generation (TTS · SVS · mixed) on ZeroGPU."""

import json
import os
import tempfile
import time
from pathlib import Path

os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")

import spaces  # MUST come before any CUDA-touching import

from sembridge import SemBridge
from sembridge.infer import _write_pcm16

import gradio as gr

MODEL_ID = "ASLP-lab/SemBridge"
EXAMPLES_DIR = Path(__file__).parent / "examples"
MAX_PATCHES = 500  # 40 s hard cap of the released checkpoint (2 × 25 Hz frames per patch)

print("Loading SemBridge checkpoint (≈4.3 GB)…", flush=True)
_t0 = time.perf_counter()
model = SemBridge.from_pretrained(MODEL_ID, device="cuda", dtype="bfloat16")
print(f"SemBridge loaded in {time.perf_counter() - _t0:.1f}s", flush=True)

# --------------------------------------------------------------- catalog
# (demo id, request json, task) — packaged straight from the official repo.
CATALOG = [
    ("tts_zh_r001", "tts/zh_r001.json", "tts"),
    ("tts_zh_r002", "tts/zh_r002.json", "tts"),
    ("tts_en_r001", "tts/en_r001.json", "tts"),
    ("tts_en_r002", "tts/en_r002.json", "tts"),
    ("svs_zh_r001", "svs/zh_r001.json", "svs"),
    ("svs_zh_r002", "svs/zh_r002.json", "svs"),
    ("svs_en_r001", "svs/en_r001.json", "svs"),
    ("svs_en_r002", "svs/en_r002.json", "svs"),
    ("unified_zh_novel_wukong", "unified/zh_novel_wukong.json", "unified"),
    ("unified_zh_robust_caihong", "unified/zh_robust_caihong.json", "unified"),
]


def _read_json(relative: str) -> dict:
    record = json.loads((EXAMPLES_DIR / relative).read_text(encoding="utf-8"))
    if not isinstance(record, dict):
        raise ValueError("request JSON must contain one object")
    return record


def _request_prompt_paths(relative: str) -> dict:
    """Resolve prompt_audio / prompt_text / generation from a packaged request.

    `prompt_audio` in the packaged JSON is relative to the *request file's*
    directory (mirroring sembridge.requests.load_request), i.e. "../audio/x.wav"
    means examples/audio/x.wav.
    """
    record = _read_json(relative)
    prompt_audio = record.get("prompt_audio")
    if prompt_audio is None:
        raise ValueError(f"{relative} has no prompt_audio")
    request_dir = (EXAMPLES_DIR / relative).parent
    prompt_path = Path(prompt_audio)
    if not prompt_path.is_absolute():
        prompt_path = request_dir / prompt_path
    if not prompt_path.is_file():
        raise FileNotFoundError(prompt_path)
    out = {
        "prompt_audio": str(prompt_path),
        "prompt_text": record.get("prompt_text", ""),
        "generation": record.get("generation") or {},
    }
    return out


VOICE_CHOICES = [  # (label, request path) for the reference-voice dropdown
    ("ZH speech 1 — Seed-TTS", "tts/zh_r001.json"),
    ("ZH speech 2 — Seed-TTS", "tts/zh_r002.json"),
    ("EN speech 1 — Common Voice", "tts/en_r001.json"),
    ("EN speech 2 — Common Voice", "tts/en_r002.json"),
    ("ZH singer 1 — GTSinger", "svs/zh_r001.json"),
    ("ZH singer 2 — GTSinger", "svs/zh_r002.json"),
    ("EN singer 1 — GTSinger", "svs/en_r001.json"),
    ("EN singer 2 — Opencpop", "svs/en_r002.json"),
    ("ZH host (spoken ref) — 悟空", "unified/zh_novel_wukong.json"),
    ("ZH ballad host (sung ref) — 彩虹", "unified/zh_robust_caihong.json"),
]
VOICE_BY_LABEL = dict(VOICE_CHOICES)
DEFAULT_TTS_VOICE = VOICE_CHOICES[0][0]
DEFAULT_SVS_VOICE = "ZH singer 1 — GTSinger"


# ------------------------------------------------------------ helpers
def _clean_number(value, fallback):
    """Gradio Number inputs give None for blank; fall back to the model default."""
    if value is None or (isinstance(value, float) and value == 0) or value == "":
        return fallback
    return value


def _generation_kwargs(request_generation: dict, flow_steps, cfg_value, temperature, seed):
    """Request `generation` block first, explicit UI values on top; None = no override."""
    legacy = {
        k: v for k, v in (request_generation or {}).items()
        if k in {"max_new_patches", "min_new_patches", "flow_steps", "cfg_value",
                 "temperature", "seed"} and v is not None
    }
    ui = {
        "flow_steps": flow_steps, "cfg_value": cfg_value,
        "temperature": temperature, "seed": seed,
    }
    legacy.update({k: v for k, v in ui.items() if v is not None})
    return legacy


def _parse_score(score_text: str) -> list[dict]:
    """Parse 'lyric|pitch|seconds' lines into sembridge note dicts."""
    notes = []
    for raw in (score_text or "").splitlines():
        line = raw.strip()
        if not line:
            continue
        parts = [p.strip() for p in line.split("|")]
        if len(parts) != 3:
            raise gr.Error(f"Each note line is 'lyric|pitch|seconds' — got: {line!r}")
        lyric, pitch, duration = parts
        try:
            pitch_i = int(pitch)
            duration_f = float(duration)
        except ValueError:
            raise gr.Error(f"Pitch must be an integer, duration a float — got: {line!r}")
        if not 0 <= pitch_i <= 128:
            raise gr.Error(f"MIDI pitch {pitch_i} is outside [0, 128].")
        if duration_f <= 0:
            raise gr.Error(f"Duration must be positive — got: {line!r}")
        notes.append({"text": lyric, "pitch": pitch_i, "duration": duration_f})
    if not notes:
        raise gr.Error("Enter at least one note: one 'lyric|pitch|seconds' per line.")
    return notes


def _score_to_ui(notes: list[dict]) -> str:
    """Render packaged SVS notes as editable UI score text."""
    return "\n".join(
        f"{n.get('text', '')}|{n.get('pitch', 0)}|{n.get('duration', 0)}" for n in notes
    )


def _estimate_duration(task: str, *args, **kwargs) -> int:
    """Callable @spaces.GPU duration: reserve what this request needs, not a blanket worst case.

    Receives the same inputs as the decorated function (positional layout of
    the handler signature when ZeroGPU passes args positionally, keywords as
    a fallback — both are accepted). Measured on ZeroGPU (RTX 6000 Ada):
    ~6.5 ms per patch×flow-step plus a small cold-start allowance and codec
    decode time.
    """
    text = kwargs.get("text") or kwargs.get("score_text") or (args[0] if args else "") or ""
    flow_steps = kwargs.get("flow_steps")
    if flow_steps is None and len(args) >= 5:
        flow_steps = args[4]
    steps = int(flow_steps) if flow_steps else 10
    steps = max(steps, 2)
    if task == "svs" and isinstance(text, str) and "|" in text:
        # Score text: sum note durations directly.
        total_seconds = 0.0
        for line in text.splitlines():
            if line.strip():
                try:
                    total_seconds += float(line.split("|")[2])
                except (IndexError, ValueError):
                    total_seconds += 0.3
        seconds = total_seconds
    else:
        # Speech: characters ≈ syllables ≈ 155 ms each (CJK 1 char/syllable).
        seconds = (len(text) if isinstance(text, str) else 0) * 0.155
    patches = min(MAX_PATCHES, max(2, int(seconds * 12.5)))
    total = 10.0 + patches * steps * 0.008 + seconds * 0.3
    return min(240, int(total * 1.3))


def _tts_duration(*args, **kwargs) -> int:
    return _estimate_duration("tts", *args, **kwargs)


def _svs_duration(*args, **kwargs) -> int:
    return _estimate_duration("svs", *args, **kwargs)


def _save_and_report(waveform, patches, started, status_box: gr.Textbox | None = None):
    out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
    _write_pcm16(Path(out.name), waveform, model.codec.sample_rate)
    seconds = waveform.size(-1) / model.codec.sample_rate
    elapsed = time.perf_counter() - started
    return out.name, (
        f"{seconds:.1f}s audio · {patches} patches · generated in {elapsed:.1f}s"
    )


# ---------------------------------------------------------------- GPU fns
@spaces.GPU(duration=_tts_duration)
def tts_generate(text: str, voice: str, ref_audio, ref_text: str,
                 flow_steps=None, cfg_value=None, temperature=None, seed=None,
                 progress=gr.Progress()):
    """Text-to-speech in the reference voice.

    Args:
        text: Sentence to speak (English or Chinese).
        voice: Packaged reference voice preset; empty string means custom upload.
        ref_audio: Optional uploaded reference recording (must be given with ref_text).
        ref_text: Transcript of the reference recording.
        flow_steps: Flow-matching steps; blank = request/model default (10).
        cfg_value: Classifier-free guidance; blank = 2.0.
        temperature: Sampling temperature; blank = 1.0.
        seed: RNG seed; blank = 42.
    """
    started = time.perf_counter()
    if not (text or "").strip():
        raise gr.Error("Enter some text to speak.")
    request_gen = {}
    if voice and voice in VOICE_BY_LABEL:
        preset = _request_prompt_paths(VOICE_BY_LABEL[voice])
        prompt_audio, prompt_text = preset["prompt_audio"], preset["prompt_text"]
        request_gen = preset["generation"]
    elif ref_audio:
        if not (ref_text or "").strip():
            raise gr.Error("An uploaded voice needs its transcript.")
        prompt_audio, prompt_text = ref_audio, ref_text.strip()
    else:
        raise gr.Error("Pick a packaged voice or upload a reference recording.")
    kwargs = _generation_kwargs(request_gen, flow_steps, cfg_value, temperature, seed)
    waveform, patches = model.generate(
        task="tts", text=text, prompt_audio=prompt_audio, prompt_text=prompt_text,
        prompt_task="speech", **kwargs,
    )
    return _save_and_report(waveform, patches.size(1), started)


@spaces.GPU(duration=_svs_duration)
def svs_generate(score_text: str, voice: str, ref_audio, ref_text: str,
                 flow_steps=None, cfg_value=None, temperature=None, seed=None,
                 progress=gr.Progress()):
    """Note-conditioned singing from a score string.

    Args:
        score_text: One note per line, "lyric|pitch|duration_seconds". "<SP>" = rest.
        voice: Packaged sung reference preset; empty string means custom upload.
        ref_audio: Optional uploaded sung reference recording.
        ref_text: Score-formatted transcript of the sung reference.
        flow_steps: Flow-matching steps; blank = 20 for singing.
        cfg_value: Classifier-free guidance; blank = 2.0.
        temperature: Sampling temperature; blank = 1.0.
        seed: RNG seed; blank = 42.
    """
    started = time.perf_counter()
    notes = _parse_score(score_text)
    request_gen = {}
    if voice and voice in VOICE_BY_LABEL:
        preset = _request_prompt_paths(VOICE_BY_LABEL[voice])
        prompt_audio, prompt_text = preset["prompt_audio"], preset["prompt_text"]
        request_gen = preset["generation"]
    elif ref_audio:
        if not (ref_text or "").strip():
            raise gr.Error("An uploaded sung reference needs its score-formatted transcript.")
        prompt_audio, prompt_text = ref_audio, ref_text.strip()
    else:
        raise gr.Error("Pick a packaged voice or upload a sung reference.")
    kwargs = _generation_kwargs(request_gen, flow_steps, cfg_value, temperature, seed)
    waveform, patches = model.generate(
        task="svs", notes=notes, prompt_audio=prompt_audio, prompt_text=prompt_text,
        prompt_task="singing", **kwargs,
    )
    return _save_and_report(waveform, patches.size(1), started)


def _unified_duration(case_id: str, *args, **kwargs) -> int:
    """Duration for the mixed speech+song cases: longest packaged sequence is
    15 s of audio at flow 20 ≈ 26 s measured; reserve ~1.4× for cold starts."""
    return 45


@spaces.GPU(duration=_unified_duration)
def unified_generate(case_id: str, flow_steps=None, cfg_value=None,
                     temperature=None, seed=None, progress=gr.Progress()):
    """Run one packaged speech→singing→speech sequence end to end.

    Args:
        case_id: Packaged case id: unified_zh_novel_wukong or unified_zh_robust_caihong.
        flow_steps: Optional flow-matching steps override.
        cfg_value: Optional guidance override.
        temperature: Optional temperature override.
        seed: Optional seed override.
    """
    started = time.perf_counter()
    if case_id not in {"unified_zh_novel_wukong", "unified_zh_robust_caihong"}:
        raise gr.Error("Case must be unified_zh_novel_wukong or unified_zh_robust_caihong.")
    record = _read_json(f"unified/{case_id.split('_', 1)[1]}.json")
    preset = _request_prompt_paths(f"unified/{case_id.split('_', 1)[1]}.json")
    kwargs = _generation_kwargs(
        preset["generation"], flow_steps, cfg_value, temperature, seed
    )
    waveform, patches = model.generate(
        task="unified", segments=record["segments"],
        prompt_audio=preset["prompt_audio"], prompt_text=preset["prompt_text"],
        prompt_task=record.get("prompt_task", "speech"), **kwargs,
    )
    return _save_and_report(waveform, patches.size(1), started)


# --------------------------------------------------------------------- UI
CSS = """
#col-container { max-width: 1100px; margin: 0 auto; }
.dark .gradio-container { color: var(--body-text-color); }
"""
CUSTOM = "⬆️ Custom upload"


def _voice_row(request: str) -> str:
    return request


def on_voice_change(voice: str):
    """Preview the packaged recording; prefill its transcript and generation settings."""
    if voice not in VOICE_BY_LABEL:
        return [gr.update(value=None, visible=True, interactive=True),
                gr.update(value=""), None, None, None, None]
    preset = _request_prompt_paths(VOICE_BY_LABEL[voice])
    gen = preset["generation"]
    return [
        gr.update(value=preset["prompt_audio"], visible=True, interactive=False),
        gr.update(value=preset["prompt_text"]),
        gen.get("flow_steps"),
        gen.get("cfg_value"),
        gen.get("temperature"),
        gen.get("seed"),
    ]


def tts_examples() -> list[list]:
    rows = []
    for label, request in VOICE_CHOICES[:4]:
        record = _read_json(request)
        rows.append([record.get("text", ""), label])
    return rows


def svs_examples() -> list[list]:
    rows = []
    for label, request in VOICE_CHOICES[4:8]:
        record = _read_json(request)
        rows.append([_score_to_ui(record.get("notes", [])), label])
    return rows


with gr.Blocks(title="SemBridge — Speech & Singing") as demo:
    gr.HTML(
        "<div style='text-align:center'>"
        "<h1>🎤 SemBridge</h1>"
        "<p style='margin:4px 0'><b>Semantic Token Anchoring for Continuous-Latent "
        "Autoregressive Speech Generation</b> — speech, singing, one voice.</p>"
        "<p style='margin:4px 0'>Model: <a href='https://huggingface.co/ASLP-lab/SemBridge'>"
        "ASLP-lab/SemBridge</a> · <a href='https://github.com/ASLP-lab/SemBridge'>Code</a> · "
        "<a href='https://arxiv.org/abs/2608.07462'>Paper</a> · "
        "<a href='https://tiamojames.github.io/SemBridge_Demo/'>Demo page</a></p>"
        "</div>"
    )
    gr.Markdown(
        "🔊 The model generates from a **reference voice** — choose one of the ten "
        "packaged speakers/singers (exact references from the authors' demo, press ▶ on "
        "the preview to listen) or upload ~4–10 s of clean audio with its transcript."
    )

    with gr.Column(elem_id="col-container"):
        with gr.Tabs():
            with gr.Tab("🗣️ Text to Speech"):
                tts_text = gr.Textbox(
                    label="Text to speak (English or Chinese)",
                    placeholder="Reading between the lines requires understanding.",
                    lines=2, autofocus=True,
                )
                tts_run = gr.Button("Generate speech", variant="primary")
                tts_out = gr.Audio(label="Generated speech", type="filepath")
                tts_status = gr.Textbox(label="Info", show_label=False, interactive=False)
                with gr.Accordion("Reference voice (required)", open=True):
                    tts_voice = gr.Dropdown(list(VOICE_BY_LABEL) + [CUSTOM],
                                            value=DEFAULT_TTS_VOICE, label="Voice")
                    tts_ref_audio = gr.Audio(
                        label="Reference recording (preview; upload when custom)",
                        type="filepath", sources=["upload"],
                    )
                    tts_ref_text = gr.Textbox(label="Reference transcript", lines=2,
                                              placeholder="Upload + transcript when set to Custom upload")
                with gr.Accordion("Generation settings", open=False):
                    with gr.Row():
                        tts_flow = gr.Number(label="Flow steps (default 10)", precision=0)
                        tts_cfg = gr.Number(label="CFG (default 2.0)")
                        tts_temp = gr.Number(label="Temperature (default 1.0)")
                        tts_seed = gr.Number(label="Seed (default 42)", precision=0)
                gr.Examples(
                    examples=tts_examples(),
                    inputs=[tts_text, tts_voice],
                    outputs=[tts_out, tts_status],
                    fn=tts_generate, cache_examples=True, cache_mode="lazy",
                    label="Official demo sentences (Seed-TTS references)",
                )

            with gr.Tab("🎵 Singing (SVS)"):
                svs_score = gr.Textbox(
                    label="Score — one note per line: lyric | MIDI pitch | duration (s). "
                          "'<SP>' = rest.",
                    placeholder="like|58|0.30\nthis|61|0.36\n<SP>|50|0.25",
                    lines=8, autofocus=True,
                )
                svs_run = gr.Button("Generate singing", variant="primary")
                svs_out = gr.Audio(label="Generated singing", type="filepath")
                svs_status = gr.Textbox(label="Info", show_label=False, interactive=False)
                with gr.Accordion("Sung reference voice (required)", open=True):
                    svs_voice = gr.Dropdown(list(VOICE_BY_LABEL) + [CUSTOM],
                                            value=DEFAULT_SVS_VOICE, label="Voice")
                    svs_ref_audio = gr.Audio(
                        label="Sung reference (preview; upload when custom)",
                        type="filepath", sources=["upload"],
                    )
                    svs_ref_text = gr.Textbox(label="Score-formatted reference transcript",
                                              lines=4)
                with gr.Accordion("Generation settings", open=False):
                    with gr.Row():
                        svs_flow = gr.Number(label="Flow steps (default 20)", precision=0)
                        svs_cfg = gr.Number(label="CFG (default 2.0)")
                        svs_temp = gr.Number(label="Temperature (default 1.0)")
                        svs_seed = gr.Number(label="Seed (default 42)", precision=0)
                gr.Examples(
                    examples=svs_examples(),
                    inputs=[svs_score, svs_voice],
                    outputs=[svs_out, svs_status],
                    fn=svs_generate, cache_examples=True, cache_mode="lazy",
                    label="Official demo scores (GTSinger / Opencpop)",
                )

            with gr.Tab("🎭 Mixed speech + song"):
                gr.Markdown(
                    "The unified model speaks, sings, then speaks again — in one "
                    "sequence, one voice. These are the two official packaged cases."
                )
                case_id = gr.Radio(
                    ["unified_zh_novel_wukong", "unified_zh_robust_caihong"],
                    value="unified_zh_novel_wukong",
                    label="Case (speech → singing → speech)",
                )
                uni_run = gr.Button("Generate mixed sequence", variant="primary")
                uni_out = gr.Audio(label="Generated mixed audio", type="filepath")
                uni_status = gr.Textbox(label="Info", show_label=False, interactive=False)
                with gr.Accordion("Generation overrides (optional)", open=False):
                    with gr.Row():
                        uni_flow = gr.Number(label="Flow steps", precision=0)
                        uni_cfg = gr.Number(label="CFG")
                        uni_temp = gr.Number(label="Temperature")
                        uni_seed = gr.Number(label="Seed", precision=0)

        gr.Markdown(
            "📝 Output is mono 44.1 kHz WAV, capped at 500 patches (~40 s). Singing "
            "durations are quantized to 25 ms frames; the score format mirrors the "
            "authors' JSON (lyric / MIDI pitch / seconds). Packaged references are "
            "Seed-TTS / GTSinger / Opencpop samples from the official demo and keep "
            "their original terms."
        )

    tts_voice.change(on_voice_change, tts_voice,
                     [tts_ref_audio, tts_ref_text, tts_flow, tts_cfg,
                      tts_temp, tts_seed])
    svs_voice.change(on_voice_change, svs_voice,
                     [svs_ref_audio, svs_ref_text, svs_flow, svs_cfg,
                      svs_temp, svs_seed])
    tts_run.click(tts_generate,
                  inputs=[tts_text, tts_voice, tts_ref_audio, tts_ref_text,
                          tts_flow, tts_cfg, tts_temp, tts_seed],
                  outputs=[tts_out, tts_status], api_name="tts")
    svs_run.click(svs_generate,
                  inputs=[svs_score, svs_voice, svs_ref_audio, svs_ref_text,
                          svs_flow, svs_cfg, svs_temp, svs_seed],
                  outputs=[svs_out, svs_status], api_name="svs")
    uni_run.click(unified_generate,
                  inputs=[case_id, uni_flow, uni_cfg, uni_temp, uni_seed],
                  outputs=[uni_out, uni_status], api_name="unified")

demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS)