| """ |
| VieNeu-TTS v3 Turbo — Hugging Face ZeroGPU Space |
| ================================================ |
| Vietnamese text-to-speech, 48 kHz, with built-in voices + instant voice cloning |
| + a multi-speaker conversation tab. Shows generation speed / RTF per request. |
| ZeroGPU notes: |
| * The model is placed on ``cuda`` at module import (ZeroGPU runs a CUDA |
| emulation outside ``@spaces.GPU`` so this is the recommended, fastest path). |
| * Real GPU compute happens only inside the ``@spaces.GPU`` decorated functions. |
| """ |
| import os |
| import re |
| import time |
| import logging |
|
|
| import numpy as np |
| import soundfile as sf |
| import gradio as gr |
| import spaces |
|
|
| |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s") |
| logger = logging.getLogger("vieneu.space") |
|
|
|
|
| def _log_infer(mode: str, text: str, *, voice=None, cloned: bool = False) -> None: |
| """Log một request infer: chế độ, giọng/clone, độ dài và nội dung text. |
| |
| Dùng repr() để text (kể cả hội thoại nhiều dòng) gói gọn trên một dòng log. |
| """ |
| src = "clone" if cloned else f"voice={voice}" |
| logger.info("[%s] %s | chars=%d | text=%r", mode, src, len(text or ""), text) |
|
|
| from vieneu import Vieneu |
| from vieneu_utils.core_utils import split_text_into_chunks |
| from vieneu_utils.phonemize_text import phonemize_text_with_emotions |
|
|
| |
| |
| |
| |
| |
| |
| import vieneu._v3_turbo_engine.hub_load_v3_turbo as _hub_load |
|
|
| _orig_load_model = _hub_load.load_model |
|
|
| def _load_model_cpu(model, filename, *args, **kwargs): |
| kwargs.pop("device", None) |
| return _orig_load_model(model, filename, *args, **kwargs) |
|
|
| _hub_load.load_model = _load_model_cpu |
|
|
| |
| print("⏳ Loading VieNeu-TTS v3 Turbo (PyTorch / CUDA) ...") |
| tts = Vieneu( |
| mode="v3turbo", |
| device="cpu", |
| backend="pytorch", |
| hf_token=os.getenv("HF_TOKEN"), |
| ) |
| print("✅ Model ready.") |
|
|
| SR = tts.sample_rate |
| PRESET_VOICES = tts.list_preset_voices() |
| VOICE_CHOICES = [(label, vid) for label, vid in PRESET_VOICES] |
| VOICE_IDS = [vid for _, vid in PRESET_VOICES] |
| VOICE_SET = set(VOICE_IDS) |
| DEFAULT_VOICE = tts._default_voice or (VOICE_IDS[0] if VOICE_IDS else None) |
| |
| DEFAULT_UI_VOICE = "Ngọc Linh" if "Ngọc Linh" in VOICE_SET else DEFAULT_VOICE |
| |
| SPK_A = VOICE_IDS[5] if VOICE_IDS else DEFAULT_VOICE |
| SPK_B = VOICE_IDS[8] if len(VOICE_IDS) > 1 else SPK_A |
|
|
| DEFAULT_TEXT = ( |
| "Xin chào mọi người! [hắng giọng] Như bạn đang nghe thấy đấy, tốc độ xử lý của mình " |
| "cực kỳ nhanh và mượt mà, giúp phản hồi gần như ngay lập tức theo thời gian thực. " |
| "Chính vì vậy, mình rất phù hợp để ứng dụng trực tiếp vào các hệ thống Chatbot thông minh, " |
| "trợ lý ảo, hoặc làm tổng đài viên tự động cho các doanh nghiệp. Tiện lợi quá đúng không ạ? " |
| "[cười] Hi vọng phiên bản nâng cấp v3 này sẽ mang lại trải nghiệm tuyệt vời cho dự án của bạn." |
| ) |
|
|
| DEFAULT_DIALOGUE = ( |
| f"{SPK_A}: Chào bạn, dạo này công việc thế nào rồi?\n" |
| f"{SPK_B}: Mình vẫn ổn, cảm ơn bạn đã hỏi thăm nhé!\n" |
| f"{SPK_A}: Nghe nói bạn vừa hoàn thành một dự án mới, chúc mừng nha!\n" |
| f"{SPK_B}: Cảm ơn nhiều, đúng là một hành trình thú vị đấy [cười].\n" |
| ) |
|
|
|
|
| def _stats_md(elapsed: float, n_samples: int) -> str: |
| """Generation-speed / real-time-factor report.""" |
| dur = n_samples / SR if SR else 0.0 |
| rtf = (elapsed / dur) if dur > 0 else 0.0 |
| speed = (dur / elapsed) if elapsed > 0 else 0.0 |
| return ( |
| f"⏱️ Thời gian sinh: **{elapsed:.2f}s**\n" |
| f"🔊 Độ dài audio: **{dur:.2f}s**\n" |
| f"⚡ RTF: **{rtf:.3f}** (×{speed:.1f} so với thời gian thực)" |
| ) |
|
|
|
|
| def _gpu_duration(text, *args, **kwargs): |
| """Dynamic ZeroGPU budget: scale with text length, capped at 3 minutes.""" |
| n = len(text or "") |
| return int(min(180, 30 + n // 8)) |
|
|
|
|
| |
| |
| |
| |
| _STREAM_CHUNK_CHARS = 140 |
|
|
|
|
| def _check_ref_len(path): |
| """Warn when the reference clip is longer than the ideal 3–5 s window.""" |
| if not path: |
| return gr.update(visible=False) |
| try: |
| dur = sf.info(path).duration |
| except Exception: |
| return gr.update(visible=False) |
| if dur > 5.5: |
| return gr.update( |
| visible=True, |
| value=( |
| f"⚠️ Audio mẫu đang dài **{dur:.1f} giây**. Hãy cắt còn " |
| f"**3–5 giây** (một câu nói rõ, ít ồn) để clone giọng tốt nhất — " |
| f"audio quá dài thường cho kết quả kém hơn." |
| ), |
| ) |
| return gr.update(visible=False) |
|
|
|
|
| |
| @spaces.GPU(duration=_gpu_duration) |
| def synthesize(text, voice, ref_audio, temperature, top_k, top_p, |
| repetition_penalty, max_new_frames, max_chars): |
| text = (text or "").strip() |
| if not text: |
| raise gr.Error("Vui lòng nhập văn bản cần đọc.") |
| _log_infer("full", text, voice=voice, cloned=bool(ref_audio)) |
|
|
| |
| |
| |
| kwargs = dict( |
| temperature=float(temperature), |
| top_k=int(top_k), |
| top_p=float(top_p), |
| repetition_penalty=float(repetition_penalty), |
| max_new_frames=int(max_new_frames), |
| max_chars=int(max_chars), |
| ) |
|
|
| t0 = time.perf_counter() |
| |
| if ref_audio: |
| wav = tts.infer(text, ref_audio=ref_audio, **kwargs) |
| else: |
| wav = tts.infer(text, voice=voice, **kwargs) |
| elapsed = time.perf_counter() - t0 |
|
|
| wav = np.asarray(wav, dtype=np.float32) |
| return (SR, wav), _stats_md(elapsed, len(wav)) |
|
|
|
|
| |
| _LINE_RE = re.compile(r"^\s*([^::]+)[::]\s*(.*)$") |
|
|
|
|
| def _parse_dialogue(script): |
| """Parse ``Speaker: line`` rows into ``[(voice_id, text), ...]``. |
| The speaker label must match a built-in voice name; unknown / missing labels |
| fall back to the default voice. |
| """ |
| turns = [] |
| for raw in (script or "").splitlines(): |
| raw = raw.strip() |
| if not raw: |
| continue |
| m = _LINE_RE.match(raw) |
| if m: |
| spk, txt = m.group(1).strip(), m.group(2).strip() |
| voice = spk if spk in VOICE_SET else DEFAULT_VOICE |
| else: |
| voice, txt = DEFAULT_VOICE, raw |
| if txt: |
| turns.append((voice, txt)) |
| return turns |
|
|
|
|
| @spaces.GPU(duration=_gpu_duration) |
| def synthesize_conversation(script, silence_s, temperature, max_new_frames): |
| turns = _parse_dialogue(script) |
| if not turns: |
| raise gr.Error("Vui lòng nhập hội thoại (mỗi dòng: 'Tên giọng: nội dung').") |
| _log_infer("conversation", (script or "").strip(), voice=f"{len(turns)} turns") |
|
|
| gap = np.zeros(int(max(0.0, float(silence_s)) * SR), dtype=np.float32) |
| pieces = [] |
| t0 = time.perf_counter() |
| for i, (voice, txt) in enumerate(turns): |
| wav = tts.infer( |
| txt, |
| voice=voice, |
| temperature=float(temperature), |
| max_new_frames=int(max_new_frames), |
| ) |
| pieces.append(np.asarray(wav, dtype=np.float32)) |
| if i < len(turns) - 1 and gap.size: |
| pieces.append(gap) |
| elapsed = time.perf_counter() - t0 |
|
|
| full = np.concatenate(pieces) if pieces else np.zeros(0, dtype=np.float32) |
| info = f"{_stats_md(elapsed, len(full))}\n👥 Số lượt thoại: **{len(turns)}**" |
| return (SR, full), info |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| @spaces.GPU(duration=_gpu_duration) |
| def stream_synthesize(text, voice, ref_audio, temperature, top_k, top_p, |
| repetition_penalty, max_new_frames, max_chars): |
| text = (text or "").strip() |
| if not text: |
| raise gr.Error("Vui lòng nhập văn bản cần đọc.") |
| _log_infer("stream", text, voice=voice, cloned=bool(ref_audio)) |
|
|
| |
| if ref_audio: |
| ref_codes, voice_token_id = tts.encode_reference(ref_audio), None |
| else: |
| ref_codes, voice_token_id = tts._resolve_v3_ref(voice, None, None) |
|
|
| for chunk in split_text_into_chunks(text, max_chars=_STREAM_CHUNK_CHARS): |
| phonemes = phonemize_text_with_emotions(chunk) |
| wav = tts.engine.infer( |
| text="", phonemes=phonemes, ref_codes=ref_codes, |
| voice_token_id=voice_token_id, emotion="natural", |
| temperature=float(temperature), top_k=int(top_k), top_p=float(top_p), |
| repetition_penalty=float(repetition_penalty), max_new_frames=int(max_new_frames), |
| ) |
| wav = np.asarray(wav, dtype=np.float32) |
| if wav.size: |
| yield (SR, wav) |
|
|
|
|
| |
| HEADER = """ |
| <div style="text-align:center;padding:22px;border-radius:14px; |
| background:linear-gradient(135deg,#0f172a 0%,#1e293b 100%);color:#fff;margin-bottom:18px;"> |
| <div style="font-size:2.1rem;font-weight:800;">🦜 VieNeu-TTS <span |
| style="background:-webkit-linear-gradient(45deg,#60A5FA,#22D3EE); |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent;">v3 Turbo</span></div> |
| <div style="margin-top:8px;font-size:0.95rem;"> |
| <a href="https://huggingface.co/pnnbao-ump/VieNeu-TTS-v3-Turbo" target="_blank" |
| style="color:#60A5FA;text-decoration:none;font-weight:600;">🤗 Model card</a> |
| · |
| <a href="https://github.com/pnnbao97/VieNeu-TTS" target="_blank" |
| style="color:#60A5FA;text-decoration:none;font-weight:600;">💻 GitHub repo</a> |
| </div> |
| <div style="opacity:.85;margin-top:6px;"> |
| Text-to-Speech tiếng Việt · 48 kHz · giọng dựng sẵn + nhân bản giọng + hội thoại |
| </div> |
| </div> |
| """ |
|
|
| VOICE_HINT = "**Giọng dựng sẵn:** " + " · ".join(f"`{v}`" for v in VOICE_IDS) |
|
|
| theme = gr.themes.Soft(primary_hue="indigo", secondary_hue="cyan", neutral_hue="slate") |
|
|
| with gr.Blocks(theme=theme, title="VieNeu-TTS v3 Turbo") as demo: |
| gr.HTML(HEADER) |
|
|
| with gr.Tabs(): |
| |
| with gr.Tab("📝 Đọc văn bản"): |
| gr.Markdown( |
| "Chèn tag cảm xúc trong văn bản (thử nghiệm): `[cười]`, `[thở dài]`, `[hắng giọng]`." |
| ) |
| with gr.Row(): |
| with gr.Column(scale=3): |
| text_in = gr.Textbox( |
| label="Văn bản", value=DEFAULT_TEXT, lines=8, |
| placeholder="Nhập văn bản tiếng Việt...", |
| ) |
| with gr.Tabs(): |
| with gr.Tab("Giọng dựng sẵn"): |
| voice_in = gr.Dropdown( |
| label="Chọn giọng", choices=VOICE_CHOICES, value=DEFAULT_UI_VOICE, |
| ) |
| with gr.Tab("Nhân bản giọng"): |
| gr.Markdown( |
| "### ⏱️ Audio mẫu nên dài **3–5 giây**\n" |
| "Dùng **một câu nói rõ ràng, ít tiếng ồn**. " |
| "**Đừng** tải lên file dài (cả đoạn/cả bài) — audio càng dài " |
| "clone càng dễ sai giọng và méo tiếng." |
| ) |
| ref_audio_in = gr.Audio( |
| label="Audio mẫu (3–5 giây)", type="filepath", |
| sources=["upload", "microphone"], |
| ) |
| ref_warn = gr.Markdown(visible=False) |
| gr.Markdown( |
| "_Có audio mẫu ở đây sẽ **ghi đè** giọng dựng sẵn. " |
| "Xoá audio để quay lại giọng dựng sẵn._" |
| ) |
| ref_audio_in.change(_check_ref_len, ref_audio_in, ref_warn) |
| with gr.Accordion("Tuỳ chọn nâng cao", open=False): |
| with gr.Row(): |
| temperature_in = gr.Slider(0.1, 1.5, value=0.8, step=0.05, label="temperature") |
| top_p_in = gr.Slider(0.1, 1.0, value=0.95, step=0.01, label="top_p") |
| with gr.Row(): |
| top_k_in = gr.Slider(1, 100, value=25, step=1, label="top_k") |
| rep_pen_in = gr.Slider(1.0, 2.0, value=1.2, step=0.05, label="repetition_penalty") |
| with gr.Row(): |
| max_frames_in = gr.Slider(50, 1200, value=300, step=10, label="max_new_frames (mỗi đoạn)") |
| max_chars_in = gr.Slider(64, 400, value=256, step=8, label="max_chars (cắt đoạn)") |
| with gr.Column(scale=2): |
| with gr.Tabs(): |
| with gr.Tab("🔊 Tạo (đầy đủ)"): |
| audio_out = gr.Audio(label="Kết quả", type="numpy") |
| stats_out = gr.Markdown(label="Tốc độ sinh / RTF") |
| run_btn = gr.Button("Tạo giọng nói", variant="primary") |
| with gr.Tab("⚡ Stream"): |
| stream_audio_out = gr.Audio( |
| label="Phát trực tiếp", streaming=True, autoplay=True, |
| ) |
| with gr.Row(): |
| stream_btn = gr.Button("⚡ Phát trực tiếp", variant="primary") |
| stop_btn = gr.Button("⏹ Dừng", variant="stop") |
| gr.Markdown( |
| "_Phát dần theo từng câu (~2 giây) ngay khi sinh xong; câu sau " |
| "được sinh sẵn trong lúc câu trước đang phát nên nghe liền mạch._" |
| ) |
|
|
| run_btn.click( |
| fn=synthesize, |
| inputs=[text_in, voice_in, ref_audio_in, temperature_in, top_k_in, |
| top_p_in, rep_pen_in, max_frames_in, max_chars_in], |
| outputs=[audio_out, stats_out], |
| ) |
| stream_evt = stream_btn.click( |
| fn=stream_synthesize, |
| inputs=[text_in, voice_in, ref_audio_in, temperature_in, top_k_in, |
| top_p_in, rep_pen_in, max_frames_in, max_chars_in], |
| outputs=stream_audio_out, |
| ) |
| stop_btn.click(fn=None, cancels=stream_evt) |
| gr.Examples( |
| examples=[ |
| [DEFAULT_TEXT, DEFAULT_UI_VOICE], |
| ["Hello everyone! [hắng giọng] As you can hear, my processing speed is incredibly fast and smooth, allowing for near-instant responses in real time. Because of this, I’m a perfect fit for direct integration into smart chatbots, virtual assistants, or automated call centers for businesses. Pretty convenient, right? [cười] I hope this version three upgrade brings an amazing experience to your project.", DEFAULT_UI_VOICE], |
| ], |
| inputs=[text_in, voice_in], |
| ) |
|
|
| |
| with gr.Tab("💬 Hội thoại"): |
| gr.Markdown( |
| "Mỗi dòng theo dạng **`Tên giọng: nội dung`**. " |
| "Tên không khớp sẽ dùng giọng mặc định.\n\n" + VOICE_HINT |
| ) |
| with gr.Row(): |
| with gr.Column(scale=3): |
| conv_in = gr.Textbox( |
| label="Kịch bản hội thoại", value=DEFAULT_DIALOGUE, lines=12, |
| placeholder=f"{SPK_A}: ...\n{SPK_B}: ...", |
| ) |
| with gr.Accordion("Tuỳ chọn nâng cao", open=False): |
| with gr.Row(): |
| conv_silence_in = gr.Slider(0.0, 1.5, value=0.35, step=0.05, label="Khoảng lặng giữa lượt (giây)") |
| conv_temp_in = gr.Slider(0.1, 1.5, value=0.8, step=0.05, label="temperature") |
| conv_frames_in = gr.Slider(50, 1200, value=300, step=10, label="max_new_frames (mỗi lượt)") |
| conv_btn = gr.Button("🎭 Tạo hội thoại", variant="primary") |
| with gr.Column(scale=2): |
| conv_audio_out = gr.Audio(label="Kết quả hội thoại", type="numpy") |
| conv_stats_out = gr.Markdown(label="Tốc độ sinh / RTF") |
|
|
| conv_btn.click( |
| fn=synthesize_conversation, |
| inputs=[conv_in, conv_silence_in, conv_temp_in, conv_frames_in], |
| outputs=[conv_audio_out, conv_stats_out], |
| ) |
|
|
| gr.Markdown( |
| "Model: [pnnbao-ump/VieNeu-TTS-v3-Turbo]" |
| "(https://huggingface.co/pnnbao-ump/VieNeu-TTS-v3-Turbo) · " |
| "Code: [github.com/pnnbao97/VieNeu-TTS](https://github.com/pnnbao97/VieNeu-TTS)" |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| demo.queue().launch() |