VoiceTut-TTS / app.py
mohammedaly22's picture
Upload app.py with huggingface_hub
dca53c3 verified
Raw
History Blame Contribute Delete
25.1 kB
"""
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)