File size: 25,073 Bytes
7c09955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70bde6f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c09955
 
 
 
 
 
 
 
98d3e11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c09955
 
 
 
 
 
 
 
 
 
 
 
338e009
7c09955
 
338e009
7c09955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad5a0a2
 
 
7c09955
ad5a0a2
 
7c09955
 
ad5a0a2
7c09955
ad5a0a2
7c09955
 
 
ad5a0a2
7c09955
 
ad5a0a2
7c09955
 
 
ad5a0a2
 
7c09955
ad5a0a2
 
 
 
 
 
 
 
 
 
7c09955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70bde6f
7c09955
 
 
 
 
 
 
 
 
 
70bde6f
7c09955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7426f99
 
 
 
 
 
 
 
 
 
 
70bde6f
7c09955
70bde6f
7c09955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7426f99
 
7c09955
 
 
 
 
 
 
 
 
 
 
 
 
98d3e11
 
7c09955
 
 
 
 
 
338e009
 
7c09955
338e009
7c09955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad5a0a2
7c09955
dca53c3
7c09955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19de693
 
 
 
7c09955
19de693
 
7c09955
 
7426f99
 
7c09955
 
 
 
 
 
 
19de693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c09955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19de693
 
7c09955
 
7426f99
 
7c09955
 
19de693
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c09955
 
 
 
 
 
98d3e11
 
 
 
 
 
 
 
7c09955
 
 
 
 
 
 
 
 
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
486
487
488
489
490
491
492
493
494
495
496
"""
VoiceTut-TTS — Gradio web app (custom-styled, black/white + blue).

Two tabs (built-in speakers / voice cloning), a language switch (Egyptian Arabic ⇄
English) that sets the synthesis language, generation parameters, and examples.

Run:
    pip install "voicetut-tts[web]"
    python app.py
    OMNICLEO_CKPT=exp/omnivoice_egy/checkpoint-8000 python app.py
    OMNICLEO_SHARE=1 python app.py        # public link
"""

import os

import gradio as gr

# HuggingFace ZeroGPU support: GPU is allocated per-request inside @spaces.GPU functions.
# Guarded so the app still runs locally / on a dedicated GPU without the `spaces` package.
try:
    import spaces
    _ZEROGPU = True
except ImportError:
    _ZEROGPU = False

    class _NoSpaces:                     # no-op decorator fallback
        @staticmethod
        def GPU(*dargs, **dkwargs):
            def deco(fn):
                return fn
            # support both @spaces.GPU and @spaces.GPU(duration=...)
            if len(dargs) == 1 and callable(dargs[0]) and not dkwargs:
                return dargs[0]
            return deco
    spaces = _NoSpaces()

from voicetut_tts import VoiceTutTTS, GenerationParams
from voicetut_tts.engine import DEFAULT_REPO

CKPT = os.environ.get("OMNICLEO_CKPT", DEFAULT_REPO)

print(f"Loading VoiceTut-TTS from {CKPT} ...")
TTS = VoiceTutTTS.from_pretrained(CKPT)
SPEAKERS = TTS.list_speakers()

# Copy each speaker's reference WAV into a local dir under the app's CWD.
# When speakers come from an HF snapshot they live in ~/.cache/huggingface, which Gradio
# refuses to serve. Mirroring them under ./reference_audio (always inside CWD) avoids the
# allowed_paths / InvalidPathError problem on HF Spaces and locally alike.
import shutil
_REF_DIR = os.path.join(os.getcwd(), "reference_audio")
os.makedirs(_REF_DIR, exist_ok=True)
REF_AUDIO = {}   # speaker_name -> local servable wav path
for s in SPEAKERS:
    try:
        dst = os.path.join(_REF_DIR, os.path.basename(s.audio_path))
        if os.path.abspath(s.audio_path) != os.path.abspath(dst):
            shutil.copyfile(s.audio_path, dst)
        REF_AUDIO[s.speaker_name] = dst
    except Exception as e:
        print(f"  (warn) couldn't stage reference for {s.speaker_name}: {e}")
        REF_AUDIO[s.speaker_name] = s.audio_path

SPK_BY_NAME = {s.speaker_name: s for s in SPEAKERS}
DEFAULT_SPK = SPEAKERS[0].speaker_name if SPEAKERS else None

# "Name  ·  ♀/♂  ·  tags" as the label, speaker_name as the value
SPEAKER_CHOICES = [
    (f"{s.speaker_name}   ·   {'♀ أنثى' if s.gender == 'female' else '♂ ذكر'}"
     f"{('   ·   ' + ' · '.join(s.tags)) if s.tags else ''}", s.speaker_name)
    for s in SPEAKERS
]

EXAMPLES = [
    ["Mohamed", "بصراحة ال feedback اللي جالي من ال manager كان كويس اوي، بس في شوية comments محتاجين نخلصها."],
    ["Abdullah", "و الموضوع محتاج دراسة و تفكير و تخطيط عشان الحاجة تتعمل صح، ف بالتالي كل ما كان عندك ايمان في نفسك و ثقة في ربنا سبحانه و تعالى هتلاقي ان كل حاجة بتمشي احسن مما انت مْخطط و مُتَخيّل كمان يا ابراهيم."],
    ["Asmaa", "اتفقنا نعمل ال meeting بكرة الصبح، فياريت كل واحد يجهز ال presentation بتاعته."],
    ["Yasmin", "النهارده الجو حلو اوي، يلا نطلع نتمشى وناخد بريك من ال laptop."],
    ["Sayed", "ان انت تبقا مش stressed، و مركز في حياتك و في بيتك و عايش عيشة كويسة ده اهم حاجة، يعني هو الواحد هيعوز ايه اكتر من كدة و مع حلة ورق عنب ع الغدا خلاص انا كدة مَلِك زماني."],
]

# blue waveform for all audio players (Gradio renders orange by default)
WAVE = gr.WaveformOptions(waveform_color="#3a6bd6", waveform_progress_color="#2f6bff")

# ---------------------------------------------------------------- theme + css
THEME = gr.themes.Soft(primary_hue="blue", neutral_hue="slate", radius_size="lg").set(
    body_background_fill="#000000", body_background_fill_dark="#000000",
    block_background_fill="#0f0f12", block_background_fill_dark="#0f0f12",
    block_border_color="#222228", block_border_color_dark="#222228",
    block_label_background_fill="#0f0f12", block_label_background_fill_dark="#0f0f12",
    input_background_fill="#16161b", input_background_fill_dark="#16161b",
    border_color_primary="#222228", border_color_primary_dark="#222228",
    button_primary_background_fill="#2f6bff", button_primary_background_fill_hover="#4f86ff",
    button_primary_text_color="#ffffff",
    button_secondary_background_fill="#16161b", button_secondary_background_fill_hover="#1d1d23",
    color_accent_soft="rgba(47,107,255,.14)",
)

CUSTOM_CSS = """
/* RTL leading order across the whole app (forced so SSR/Colab honor it) */
.gradio-container, .gradio-container *:not(.vt-ltr) { direction: rtl; }
.gradio-container { max-width: 1200px !important; width: 100% !important; margin: 0 auto !important;
  font-family: 'Cairo','Inter',system-ui,sans-serif !important; }
/* keep latin-only widgets natural where needed */
input[type=range], .vt-ltr, .vt-ltr * { direction: ltr; }
footer { display: none !important; }

/* hero — responsive (wraps + recenters on mobile) */
#vt-hero { display:flex; align-items:center; gap:16px; padding:22px 26px; margin:8px 0 18px;
  border:1px solid #222228; border-radius:20px; flex-wrap:wrap;
  background:linear-gradient(120deg,#0d0d10,#121218);
  box-shadow:0 16px 50px rgba(0,0,0,.6); }
#vt-hero .logo { width:52px; height:52px; border-radius:14px; display:grid; place-items:center;
  font-size:26px; background:linear-gradient(135deg,#2f6bff,#4f86ff); color:#fff; flex:0 0 auto;
  box-shadow:0 8px 26px rgba(47,107,255,.45); animation:vtfloat 5s ease-in-out infinite; }
@keyframes vtfloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-5px)} }
#vt-hero .vt-hero-txt { min-width: 0; flex: 1 1 200px; }
#vt-hero h1 { font-size:26px; font-weight:800; margin:0; color:#f5f6f8; letter-spacing:-.5px; }
#vt-hero .accent { background:linear-gradient(90deg,#4f86ff,#8ab0ff);
  -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent; }
#vt-hero p { margin:3px 0 0; color:#8a8d96; font-size:14px; line-height:1.6; }
#vt-hero .badges { margin-inline-start:auto; display:flex; gap:8px; flex-wrap:wrap; }
#vt-hero .badge { font-size:12px; font-weight:700; color:#9fb6ff; padding:5px 12px;
  border:1px solid rgba(47,107,255,.3); border-radius:20px; background:rgba(47,107,255,.1); white-space:nowrap; }

/* mobile */
@media (max-width: 600px) {
  #vt-hero { padding:16px 18px; gap:12px; justify-content:center; text-align:center; }
  #vt-hero h1 { font-size:21px; }
  #vt-hero p { font-size:12.5px; }
  #vt-hero .badges { margin-inline-start:0; width:100%; justify-content:center; }
  .gradio-container { padding:0 8px !important; }
}

/* generate button */
#vt-generate { font-weight:800 !important; font-size:16px !important; padding:14px !important;
  box-shadow:0 8px 26px rgba(47,107,255,.35) !important; transition:all .2s ease !important; }
#vt-generate:hover { transform:translateY(-2px) !important; box-shadow:0 12px 34px rgba(47,107,255,.45) !important; }

/* tabs */
.tab-nav button { font-weight:700 !important; font-size:15px !important; }
.tab-nav button.selected { color:#4f86ff !important; border-bottom:2px solid #2f6bff !important; }

/* single column: full container width, each child a flat card, evenly spaced */
.vt-col { width:100% !important; max-width:100% !important; margin:0 auto !important;
  display:flex !important; flex-direction:column !important; gap:14px !important; }
.vt-col > * { width:100% !important; }

/* streaming metrics cards */
.vt-metrics { display:flex; flex-wrap:wrap; gap:12px; margin-top:6px; }
.vt-metric { flex:1 1 130px; display:flex; flex-direction:column; align-items:center; gap:4px;
  padding:14px 10px; border:1px solid #222228; border-radius:14px;
  background:linear-gradient(160deg,#101218,#0c0c10); box-shadow:0 8px 24px rgba(0,0,0,.35); }
.vt-metric .ic { font-size:20px; }
.vt-metric .val { font-size:20px; font-weight:800; color:#4f86ff; letter-spacing:-.5px; }
.vt-metric .lbl { font-size:11px; color:#8a8d96; font-weight:600; text-align:center; }

/* streaming toggle */
#vt-stream { background:#101218 !important; border:1px solid #222228 !important;
  border-radius:12px !important; padding:10px 14px !important; }

/* inputs */
textarea, input { font-size:15px !important; }
textarea:focus, input:focus { border-color:#2f6bff !important;
  box-shadow:0 0 0 2px rgba(47,107,255,.18) !important; }
input[type=range]::-webkit-slider-thumb { background:#2f6bff !important; }

/* audio: remove the stray white border/outline, keep a clean blue-tinted card */
.vt-audio, .vt-audio * { outline:none !important; }
.vt-audio { border:1px solid #222228 !important; border-radius:14px !important; }

/* language switch -> segmented blue pill */
#vt-lang fieldset { border:none !important; display:flex !important; gap:6px !important;
  background:#0f0f12 !important; border:1px solid #222228 !important; padding:6px !important;
  border-radius:30px !important; width:fit-content !important; }
#vt-lang fieldset > div { display:flex !important; gap:6px !important; }
#vt-lang label { border-radius:24px !important; transition:all .2s ease !important;
  font-weight:700 !important; padding:8px 18px !important; border:none !important; cursor:pointer; }
#vt-lang label:has(input:checked) { background:#2f6bff !important; color:#fff !important;
  box-shadow:0 4px 14px rgba(47,107,255,.4) !important; }

/* generate button spacing */
#vt-generate { margin:6px 0 !important; }

/* examples */
.gr-samples-table tr:hover { background:rgba(47,107,255,.12) !important; }
"""


# ---------------------------------------------------------------- callbacks
def _params(num_step, guidance, speed):
    return GenerationParams(num_step=int(num_step), guidance_scale=float(guidance), speed=float(speed))


def _lang_code(language):
    return "en" if "English" in (language or "") else "arz"


@spaces.GPU(duration=120)
def gen_builtin(speaker_name, text, language, num_step, guidance, speed, normalize):
    if not text or not text.strip():
        raise gr.Error("اكتب النص الأول من فضلك")
    if not speaker_name:
        raise gr.Error("اختار صوت")
    wav = TTS.synthesize(text.strip(), speaker=speaker_name, language=_lang_code(language),
                         normalize=normalize, params=_params(num_step, guidance, speed))
    return (TTS.sampling_rate, wav)


@spaces.GPU(duration=120)
def gen_clone(ref_audio, ref_text, text, language, num_step, guidance, speed, normalize):
    if not text or not text.strip():
        raise gr.Error("اكتب النص الأول")
    if not ref_audio:
        raise gr.Error("ارفع ملف صوتي الأول")
    wav = TTS.synthesize(text.strip(), ref_audio=ref_audio, ref_text=(ref_text or None),
                         language=_lang_code(language), normalize=normalize,
                         params=_params(num_step, guidance, speed))
    return (TTS.sampling_rate, wav)


def _metrics_html(chunks, ttfa, total, audio_secs, vram_gb):
    """Render the streaming metrics as styled cards."""
    rtf = (total / audio_secs) if audio_secs else 0.0
    vram = f"{vram_gb:.2f} GB" if vram_gb is not None else "—"
    cards = [
        ("🔊", "مقاطع / Chunks", str(chunks)),
        ("⚡", "زمن أول صوت / TTFA", f"{ttfa:.2f}s"),
        ("⏱️", "الزمن الكلي / Latency", f"{total:.2f}s"),
        ("🎚️", "RTF", f"{rtf:.2f}×"),
        ("💾", "ذاكرة الكارت / VRAM", vram),
    ]
    body = "".join(
        f'<div class="vt-metric"><span class="ic">{ic}</span>'
        f'<span class="val">{val}</span><span class="lbl">{lbl}</span></div>'
        for ic, lbl, val in cards
    )
    return f'<div class="vt-metrics">{body}</div>'


def _peak_vram_gb():
    try:
        import torch
        if torch.cuda.is_available():
            return torch.cuda.max_memory_allocated() / (1024 ** 3)
    except Exception:
        pass
    return None


def _chunk_to_wav_bytes(sr, wav):
    """Encode one audio chunk as a standalone WAV byte-string for streaming output."""
    import io
    import numpy as np
    import soundfile as sf
    buf = io.BytesIO()
    arr = np.asarray(wav, dtype=np.float32)
    sf.write(buf, arr, sr, format="WAV")
    return buf.getvalue()


@spaces.GPU(duration=180)
def stream_tts(text, speaker_name, ref_audio, ref_text, language,
               num_step, guidance, speed, normalize, use_clone=False):
    """Stream long text sentence-by-sentence: yields (audio_chunk, metrics_html)."""
    import time
    if not text or not text.strip():
        raise gr.Error("اكتب النص الأول")
    voice = dict(ref_audio=ref_audio, ref_text=(ref_text or None)) if use_clone \
        else dict(speaker=speaker_name)
    if use_clone and not ref_audio:
        raise gr.Error("ارفع ملف صوتي الأول")
    if not use_clone and not speaker_name:
        raise gr.Error("اختار صوت")

    try:
        import torch
        if torch.cuda.is_available():
            torch.cuda.reset_peak_memory_stats()
    except Exception:
        pass

    t0 = time.time()
    ttfa = 0.0
    n = 0
    audio_secs = 0.0
    for sr, chunk in TTS.stream(text.strip(), language=_lang_code(language),
                                normalize=normalize, params=_params(num_step, guidance, speed),
                                **voice):
        n += 1
        if n == 1:
            ttfa = time.time() - t0
        audio_secs += len(chunk) / sr
        total = time.time() - t0
        metrics = _metrics_html(n, ttfa, total, audio_secs, _peak_vram_gb())
        # yield WAV bytes -> a streaming gr.Audio appends & plays each chunk as it arrives
        yield _chunk_to_wav_bytes(sr, chunk), metrics


def preview_reference(speaker_name):
    """Show the selected built-in speaker's reference audio + text + a tags chip row."""
    if not speaker_name or speaker_name not in SPK_BY_NAME:
        return None, "", ""
    spk = SPK_BY_NAME[speaker_name]
    chips = "".join(
        f'<span class="vt-chip">{tg}</span>' for tg in spk.tags
    )
    gender = "أنثى ♀" if spk.gender == "female" else "ذكر ♂"
    header = (f'<div class="vt-spk-meta"><span class="vt-gender">{gender}</span>'
              f'<div class="vt-chips">{chips}</div></div>')
    # use the locally-staged copy so Gradio can serve it (HF snapshot paths are blocked)
    return REF_AUDIO.get(speaker_name, spk.audio_path), spk.reference_text, header


# ---------------------------------------------------------------- reusable blocks
def advanced_block():
    with gr.Accordion("⚙️ إعدادات متقدمة", open=False):
        with gr.Row():
            ns = gr.Slider(8, 64, value=64, step=1, label="خطوات التوليد / Steps")
            gs = gr.Slider(1.0, 5.0, value=2.5, step=0.1, label="قوة التوجيه / Guidance")
        with gr.Row():
            sp = gr.Slider(0.5, 2.0, value=0.95, step=0.05, label="السرعة / Speed")
            nm = gr.Checkbox(value=True, label="تطبيع النص / Normalize")
    return ns, gs, sp, nm


# extra CSS for the speaker chips (injected via the HTML preview)
CHIP_CSS = """
<style>
.vt-spk-meta { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:2px 0 4px; }
.vt-gender { font-size:12px; font-weight:700; color:#cfd3da; padding:3px 12px;
  border:1px solid #2a2a31; border-radius:20px; }
.vt-chips { display:flex; gap:6px; flex-wrap:wrap; }
.vt-chip { font-size:12px; font-weight:700; color:#9fb6ff; padding:3px 12px;
  border:1px solid rgba(47,107,255,.3); border-radius:20px; background:rgba(47,107,255,.12); }
</style>
"""


# set RTL leading order on load
RTL_JS = """
() => {
  const root = document.querySelector('gradio-app') || document.body;
  root.setAttribute('dir', 'rtl');
  document.documentElement.setAttribute('dir', 'rtl');
  document.documentElement.setAttribute('lang', 'ar');
}
"""

# ---------------------------------------------------------------- build app
_GR_MAJOR = int(gr.__version__.split(".")[0])
_blocks_kwargs = {"title": "VoiceTut-TTS"}
if _GR_MAJOR < 6:
    _blocks_kwargs.update(css=CUSTOM_CSS, theme=THEME, js=RTL_JS)

with gr.Blocks(**_blocks_kwargs) as demo:
    gr.HTML(CHIP_CSS +
        '<div id="vt-hero">'
        '<div class="logo">𓋹</div>'
        '<div class="vt-hero-txt"><h1>VoiceTut<span class="accent">-TTS</span></h1>'
        '<p>تحويل النص إلى كلام — مصري وإنجليزي · Egyptian Arabic & code-switching TTS</p></div>'
        '<div class="badges"><span class="badge">17 صوت</span>'
        '<span class="badge">Zero-shot</span><span class="badge">Streaming</span></div>'
        '</div>'
    )

    with gr.Tabs():
        # ============================================= built-in (single column)
        with gr.Tab("🎙️ الأصوات الجاهزة"):
            with gr.Column(elem_classes="vt-col"):
                speaker = gr.Dropdown(
                    choices=SPEAKER_CHOICES, value=DEFAULT_SPK,
                    label="🗣️ الصوت", interactive=True, allow_custom_value=False,
                    filterable=True,
                )
                spk_meta = gr.HTML()
                ref_prev = gr.Audio(label="الصوت المرجعي", interactive=False,
                                    elem_classes="vt-audio", waveform_options=WAVE)
                ref_txt = gr.Textbox(label="النص المرجعي",
                                     interactive=False, lines=2)

                text_b = gr.Textbox(label="📝 النص اللي عايز تنطقه", lines=5,
                                    placeholder="اكتب النص هنا... مثال: ازيك عامل ايه النهاردة؟")

                language = gr.Radio(["العربية (Egyptian)", "English"], value="العربية (Egyptian)",
                                    label="🌐 اللغة / Language", elem_id="vt-lang")

                ns_b, gs_b, sp_b, nm_b = advanced_block()
                stream_b = gr.Checkbox(value=False, elem_id="vt-stream",
                                       label="🌊 بث مباشر للنصوص الطويلة / Stream long text (audio chunks)")
                # two generate buttons: one-shot (default) vs streaming. The streaming
                # button + its streaming Audio are shown only when the checkbox is on.
                # (A streaming=True output can't share a click handler with a one-shot
                # output — Gradio routes the whole event as a stream and errors otherwise.)
                gen_b = gr.Button("🔊 توليد الصوت", variant="primary", elem_id="vt-generate")
                gen_b_stream = gr.Button("🌊 توليد بالبث المباشر", variant="primary",
                                         elem_id="vt-generate", visible=False)
                out_b = gr.Audio(label="الناتج", type="numpy", elem_classes="vt-audio",
                                 waveform_options=WAVE, autoplay=True)
                out_b_stream = gr.Audio(label="الناتج (بث مباشر)", streaming=True, autoplay=True,
                                        elem_classes="vt-audio", visible=False)
                metrics_b = gr.HTML()

                gr.Examples(EXAMPLES, inputs=[speaker, text_b], label="أمثلة",
                            run_on_click=False, cache_examples=False)

            speaker.change(preview_reference, speaker, [ref_prev, ref_txt, spk_meta])

            # checkbox swaps which button + output is visible
            def _toggle_stream(stream_on):
                return (gr.update(visible=not stream_on), gr.update(visible=stream_on),
                        gr.update(visible=not stream_on), gr.update(visible=stream_on))
            stream_b.change(_toggle_stream, stream_b,
                            [gen_b, gen_b_stream, out_b, out_b_stream])

            # one-shot: plain function (NOT a generator) -> normal audio player
            def run_b_oneshot(speaker_name, text, language, ns, gs, sp, nm):
                return gen_builtin(speaker_name, text, language, ns, gs, sp, nm), ""

            # streaming: generator -> streaming audio player
            def run_b_stream(speaker_name, text, language, ns, gs, sp, nm):
                for chunk, met in stream_tts(text, speaker_name, None, None, language,
                                             ns, gs, sp, nm, use_clone=False):
                    yield chunk, met

            gen_b.click(run_b_oneshot, [speaker, text_b, language, ns_b, gs_b, sp_b, nm_b],
                        [out_b, metrics_b])
            gen_b_stream.click(run_b_stream, [speaker, text_b, language, ns_b, gs_b, sp_b, nm_b],
                               [out_b_stream, metrics_b])

        # ============================================= clone (single column)
        with gr.Tab("استنساخ صوت"):
            with gr.Column(elem_classes="vt-col"):
                ref_audio_c = gr.Audio(label="🎤 الصوت المرجعي audio",
                                       sources=["upload", "microphone"], type="filepath",
                                       elem_classes="vt-audio", waveform_options=WAVE)
                ref_text_c = gr.Textbox(label="النص المرجعي (اختياري)", lines=2,
                                        placeholder="اتركه فاضي للكتابة التلقائية / leave empty to auto-transcribe")

                text_c = gr.Textbox(label="📝 النص اللي عايز تنطقه", lines=5,
                                    placeholder="اكتب النص هنا...")

                language_c = gr.Radio(["العربية (Egyptian)", "English"], value="العربية (Egyptian)",
                                      label="🌐 اللغة / Language", elem_id="vt-lang")

                ns_c, gs_c, sp_c, nm_c = advanced_block()
                stream_c = gr.Checkbox(value=False, elem_id="vt-stream",
                                       label="🌊 بث مباشر للنصوص الطويلة / Stream long text (audio chunks)")
                gen_c = gr.Button("🔊 توليد الصوت", variant="primary", elem_id="vt-generate")
                gen_c_stream = gr.Button("🌊 توليد بالبث المباشر", variant="primary",
                                         elem_id="vt-generate", visible=False)
                out_c = gr.Audio(label="الناتج", type="numpy", elem_classes="vt-audio",
                                 waveform_options=WAVE, autoplay=True)
                out_c_stream = gr.Audio(label="الناتج (بث مباشر)", streaming=True, autoplay=True,
                                        elem_classes="vt-audio", visible=False)
                metrics_c = gr.HTML()

            stream_c.change(_toggle_stream, stream_c,
                            [gen_c, gen_c_stream, out_c, out_c_stream])

            def run_c_oneshot(ref_audio, ref_text, text, language, ns, gs, sp, nm):
                return gen_clone(ref_audio, ref_text, text, language, ns, gs, sp, nm), ""

            def run_c_stream(ref_audio, ref_text, text, language, ns, gs, sp, nm):
                for chunk, met in stream_tts(text, None, ref_audio, ref_text, language,
                                             ns, gs, sp, nm, use_clone=True):
                    yield chunk, met

            gen_c.click(run_c_oneshot, [ref_audio_c, ref_text_c, text_c, language_c,
                                        ns_c, gs_c, sp_c, nm_c], [out_c, metrics_c])
            gen_c_stream.click(run_c_stream, [ref_audio_c, ref_text_c, text_c, language_c,
                                              ns_c, gs_c, sp_c, nm_c], [out_c_stream, metrics_c])

    # initial preview for the default speaker
    demo.load(preview_reference, speaker, [ref_prev, ref_txt, spk_meta])


if __name__ == "__main__":
    # Allow serving the staged reference dir, the registry dir, and the HF cache
    # (covers both local checkpoints and HF-snapshot speakers).
    allowed = [_REF_DIR]
    if TTS.registry:
        allowed.append(TTS.registry.base_dir)
    hf_cache = os.path.join(os.path.expanduser("~"), ".cache", "huggingface")
    if os.path.isdir(hf_cache):
        allowed.append(hf_cache)
    launch_kwargs = dict(
        server_name="0.0.0.0",
        server_port=int(os.environ.get("OMNICLEO_PORT", "7860")),
        share=os.environ.get("OMNICLEO_SHARE", "0") == "1",
        allowed_paths=allowed,
    )
    if _GR_MAJOR >= 6:                    # Gradio 6: css/theme/js belong on launch()
        launch_kwargs.update(css=CUSTOM_CSS, theme=THEME, js=RTL_JS)
    demo.queue().launch(**launch_kwargs)