| |
| |
| |
| |
|
|
| import os |
| import re |
| import io |
| import base64 |
| import gradio as gr |
| import numpy as np |
| import torch |
| from huggingface_hub import snapshot_download, login |
|
|
| |
| HF_TOKEN = os.environ.get('HF_TOKEN') |
| if HF_TOKEN: |
| login(token=HF_TOKEN, add_to_git_credential=False) |
| else: |
| print("Cảnh báo: Không tìm thấy HF_TOKEN trong môi trường. Chỉ sử dụng các mô hình công khai.") |
|
|
| |
| MODEL_SIZES = ["0.6B", "1.7B"] |
|
|
| |
| SPEAKERS = [ |
| "Aiden", "Dylan", "Eric", "Ono_anna", "Ryan", "Serena", "Sohee", "Uncle_fu", "Vivian" |
| ] |
| LANGUAGES = [ |
| "Tự động", "Tiếng Trung", "Tiếng Anh", "Tiếng Nhật", "Tiếng Hàn", |
| "Tiếng Pháp", "Tiếng Đức", "Tiếng Tây Ban Nha", "Tiếng Bồ Đào Nha", "Tiếng Nga" |
| ] |
|
|
| LANGUAGE_MAP = { |
| "Tự động": "Auto", |
| "Tiếng Trung": "Chinese", |
| "Tiếng Anh": "English", |
| "Tiếng Nhật": "Japanese", |
| "Tiếng Hàn": "Korean", |
| "Tiếng Pháp": "French", |
| "Tiếng Đức": "German", |
| "Tiếng Tây Ban Nha": "Spanish", |
| "Tiếng Bồ Đào Nha": "Portuguese", |
| "Tiếng Nga": "Russian", |
| } |
|
|
|
|
| |
| |
| |
|
|
| def split_into_chunks(text: str, max_chars: int = 200) -> list: |
| """ |
| Chia văn bản thành các chunk theo câu/đoạn. |
| Ưu tiên chia ở dấu câu kết thúc câu, sau đó dấu phẩy, sau đó khoảng trắng. |
| Không giới hạn số lượng chunk. |
| """ |
| text = text.strip() |
| if not text: |
| return [] |
|
|
| if len(text) <= max_chars: |
| return [text] |
|
|
| chunks = [] |
| paragraphs = re.split(r'\n\s*\n', text) |
|
|
| for para in paragraphs: |
| para = para.strip() |
| if not para: |
| continue |
|
|
| if len(para) <= max_chars: |
| chunks.append(para) |
| continue |
|
|
| |
| sentences = re.split(r'(?<=[.!?…。!?])\s+', para) |
| current = "" |
|
|
| for sent in sentences: |
| sent = sent.strip() |
| if not sent: |
| continue |
|
|
| |
| if len(sent) > max_chars: |
| sub_parts = re.split(r'(?<=[,;])\s+', sent) |
| for part in sub_parts: |
| part = part.strip() |
| if not part: |
| continue |
| if len(current) + len(part) + 1 <= max_chars: |
| current = (current + " " + part).strip() |
| else: |
| if current: |
| chunks.append(current) |
| |
| if len(part) > max_chars: |
| words = part.split() |
| current = "" |
| for w in words: |
| if len(current) + len(w) + 1 <= max_chars: |
| current = (current + " " + w).strip() |
| else: |
| if current: |
| chunks.append(current) |
| current = w |
| else: |
| current = part |
| else: |
| if len(current) + len(sent) + 1 <= max_chars: |
| current = (current + " " + sent).strip() |
| else: |
| if current: |
| chunks.append(current) |
| current = sent |
|
|
| if current: |
| chunks.append(current) |
|
|
| return [c for c in chunks if c.strip()] |
|
|
|
|
| |
| |
| |
|
|
| def _normalize_audio(wav, eps=1e-12, clip=True): |
| """Chuẩn hóa âm thanh về float32 trong khoảng [-1, 1].""" |
| x = np.asarray(wav) |
| if np.issubdtype(x.dtype, np.integer): |
| info = np.iinfo(x.dtype) |
| if info.min < 0: |
| y = x.astype(np.float32) / max(abs(info.min), info.max) |
| else: |
| mid = (info.max + 1) / 2.0 |
| y = (x.astype(np.float32) - mid) / mid |
| elif np.issubdtype(x.dtype, np.floating): |
| y = x.astype(np.float32) |
| m = np.max(np.abs(y)) if y.size else 0.0 |
| if m > 1.0 + 1e-6: |
| y = y / (m + eps) |
| else: |
| raise TypeError(f"Kiểu dữ liệu không được hỗ trợ: {x.dtype}") |
| if clip: |
| y = np.clip(y, -1.0, 1.0) |
| if y.ndim > 1: |
| y = np.mean(y, axis=-1).astype(np.float32) |
| return y |
|
|
|
|
| def _audio_to_tuple(audio): |
| """ |
| Chuyển đổi đầu vào âm thanh từ Gradio thành tuple (wav, sr). |
| Hỗ trợ tất cả format Gradio có thể trả về: |
| - filepath string (Gradio 4.x+ với type='filepath') |
| - (sr, wav) tuple (Gradio cũ với type='numpy') |
| - dict {"sampling_rate": sr, "data": wav} |
| """ |
| if audio is None: |
| return None |
|
|
| |
| if isinstance(audio, str): |
| if not os.path.exists(audio): |
| return None |
| try: |
| import soundfile as sf |
| wav, sr = sf.read(audio, dtype="float32") |
| wav = _normalize_audio(wav) |
| return wav, int(sr) |
| except Exception: |
| pass |
| try: |
| from scipy.io import wavfile |
| sr, wav = wavfile.read(audio) |
| wav = _normalize_audio(wav) |
| return wav, int(sr) |
| except Exception: |
| pass |
| return None |
|
|
| |
| if isinstance(audio, tuple) and len(audio) == 2: |
| sr, wav = audio |
| if isinstance(sr, int) and isinstance(wav, np.ndarray): |
| wav = _normalize_audio(wav) |
| return wav, int(sr) |
|
|
| |
| if isinstance(audio, dict) and "sampling_rate" in audio and "data" in audio: |
| sr = int(audio["sampling_rate"]) |
| wav = _normalize_audio(audio["data"]) |
| return wav, sr |
|
|
| return None |
|
|
|
|
| def wav_to_base64(wav: np.ndarray, sr: int) -> str: |
| """Chuyển numpy audio array sang base64 WAV string để nhúng vào HTML.""" |
| import wave |
| buf = io.BytesIO() |
| wav_int16 = (np.clip(wav, -1.0, 1.0) * 32767).astype(np.int16) |
| with wave.open(buf, 'wb') as wf: |
| wf.setnchannels(1) |
| wf.setsampwidth(2) |
| wf.setframerate(sr) |
| wf.writeframes(wav_int16.tobytes()) |
| return base64.b64encode(buf.getvalue()).decode('utf-8') |
|
|
|
|
| def concat_audio(audio_list: list) -> tuple: |
| """Ghép nhiều đoạn audio (wav, sr) thành một đoạn liên tục.""" |
| if not audio_list: |
| return None |
| sr = audio_list[0][1] |
| combined = np.concatenate([a[0] for a in audio_list]) |
| return combined, sr |
|
|
|
|
| def make_chunk_done_html(chunk_idx: int, total: int, text: str, wav: np.ndarray, sr: int) -> str: |
| """Tạo HTML block cho một chunk đã xử lý xong, có audio player.""" |
| b64 = wav_to_base64(wav, sr) |
| short_text = text[:100] + "..." if len(text) > 100 else text |
| escaped = short_text.replace('"', '"').replace('<', '<').replace('>', '>') |
| return f"""<div style="border:1px solid #d1d5db; border-radius:10px; padding:14px; margin-bottom:10px; background:#ffffff; box-shadow:0 1px 3px rgba(0,0,0,0.06);"> |
| <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;"> |
| <span style="font-weight:700; color:#4f46e5; font-size:14px;">Đoạn {chunk_idx}<span style="font-weight:400; color:#9ca3af;"> / {total}</span></span> |
| <span style="font-size:12px; background:#d1fae5; color:#065f46; padding:2px 8px; border-radius:999px;">✅ Hoàn tất</span> |
| </div> |
| <p style="font-size:13px; color:#4b5563; margin:0 0 10px 0; font-style:italic; line-height:1.5;">"{escaped}"</p> |
| <audio controls style="width:100%; height:34px; border-radius:6px;"> |
| <source src="data:audio/wav;base64,{b64}" type="audio/wav"> |
| </audio> |
| </div>""" |
|
|
|
|
| def make_chunk_processing_html(chunk_idx: int, total: int, text: str) -> str: |
| """Tạo HTML placeholder khi một chunk đang xử lý.""" |
| short_text = text[:100] + "..." if len(text) > 100 else text |
| escaped = short_text.replace('"', '"').replace('<', '<').replace('>', '>') |
| return f"""<div style="border:1px dashed #a5b4fc; border-radius:10px; padding:14px; margin-bottom:10px; background:#f5f3ff;"> |
| <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;"> |
| <span style="font-weight:700; color:#4f46e5; font-size:14px;">Đoạn {chunk_idx}<span style="font-weight:400; color:#9ca3af;"> / {total}</span></span> |
| <span style="font-size:12px; background:#fef3c7; color:#92400e; padding:2px 8px; border-radius:999px;">⏳ Đang xử lý...</span> |
| </div> |
| <p style="font-size:13px; color:#6b7280; margin:0; font-style:italic; line-height:1.5;">"{escaped}"</p> |
| </div>""" |
|
|
|
|
| def make_chunk_error_html(chunk_idx: int, total: int, error: str) -> str: |
| """Tạo HTML block khi một chunk gặp lỗi.""" |
| return f"""<div style="border:1px solid #fca5a5; border-radius:10px; padding:12px; margin-bottom:10px; background:#fef2f2;"> |
| <div style="display:flex; justify-content:space-between; align-items:center;"> |
| <span style="font-weight:700; color:#dc2626; font-size:14px;">Đoạn {chunk_idx} / {total}</span> |
| <span style="font-size:12px; background:#fee2e2; color:#991b1b; padding:2px 8px; border-radius:999px;">❌ Lỗi</span> |
| </div> |
| <p style="font-size:13px; color:#7f1d1d; margin:6px 0 0 0;">{error}</p> |
| </div>""" |
|
|
|
|
| def wrap_chunk_area(inner_html: str) -> str: |
| """Bọc danh sách chunk trong container có scroll.""" |
| return f"""<div style="max-height:520px; overflow-y:auto; padding-right:4px;">{inner_html}</div>""" |
|
|
|
|
| |
| |
| |
|
|
| def preview_chunks(text: str, chunk_size: int) -> str: |
| """Hiển thị preview danh sách chunks sẽ được tạo.""" |
| if not text or not text.strip(): |
| return "<p style='color:#9ca3af; font-style:italic; padding:8px;'>Nhập văn bản để xem trước các đoạn...</p>" |
| chunks = split_into_chunks(text.strip(), max_chars=int(chunk_size)) |
| if not chunks: |
| return "<p style='color:#9ca3af;'>Không có đoạn nào.</p>" |
| rows = "" |
| for i, c in enumerate(chunks, 1): |
| short = c[:120] + "..." if len(c) > 120 else c |
| escaped = short.replace('<', '<').replace('>', '>') |
| rows += f"""<div style="display:flex; gap:10px; padding:9px 4px; border-bottom:1px solid #f3f4f6; align-items:baseline;"> |
| <span style="min-width:56px; font-weight:700; color:#6366f1; font-size:13px;">Đoạn {i}</span> |
| <span style="color:#374151; font-size:13px; flex:1; line-height:1.4;">{escaped}</span> |
| <span style="color:#9ca3af; font-size:11px; white-space:nowrap;">{len(c)} ký tự</span> |
| </div>""" |
| header = f"""<div style="font-weight:600; color:#374151; margin-bottom:10px; font-size:14px;"> |
| 📋 Sẽ chia thành <b style="color:#4f46e5;">{len(chunks)}</b> đoạn |
| <span style="font-weight:400; color:#9ca3af; font-size:12px;"> · Tổng {len(text.strip())} ký tự</span> |
| </div>""" |
| return wrap_chunk_area(f"""<div style="border:1px solid #e0e7ff; border-radius:12px; padding:14px; background:#fafafa;">{header}{rows}</div>""") |
|
|
|
|
| |
| |
| |
| _loaded_models = {} |
|
|
|
|
| def get_model_path(model_type: str, model_size: str) -> str: |
| return snapshot_download( |
| f"Qwen/Qwen3-TTS-12Hz-{model_size}-{model_type}", |
| token=HF_TOKEN if HF_TOKEN else None |
| ) |
|
|
|
|
| def get_model(model_key: str, model_type: str, model_size: str): |
| global _loaded_models |
| if model_key not in _loaded_models: |
| print(f"Đang tải mô hình {model_type} {model_size} lên CPU...") |
| try: |
| from qwen_tts import Qwen3TTSModel |
| model_path = get_model_path(model_type, model_size) |
| _loaded_models[model_key] = Qwen3TTSModel.from_pretrained( |
| model_path, |
| device_map="cpu", |
| dtype=torch.float32, |
| token=HF_TOKEN if HF_TOKEN else None, |
| attn_implementation=None, |
| ) |
| print(f"Đã tải thành công mô hình {model_type} {model_size} trên CPU!") |
| except Exception as e: |
| print(f"Lỗi khi tải mô hình: {e}") |
| raise |
| return _loaded_models[model_key] |
|
|
|
|
| |
| |
| |
|
|
| def _run_chunked(chunks, generate_fn, total): |
| """ |
| Chạy generate_fn cho từng chunk, yield (all_audio, html_blocks, status, i). |
| generate_fn(chunk) -> (wav_array, sr) |
| """ |
| all_audio = [] |
| html_blocks = [] |
|
|
| for i, chunk in enumerate(chunks, 1): |
| |
| processing = make_chunk_processing_html(i, total, chunk) |
| yield all_audio, html_blocks + [processing], f"Đang xử lý đoạn {i}/{total}...", i |
|
|
| try: |
| wav, sr = generate_fn(chunk) |
| all_audio.append((wav, sr)) |
| html_blocks.append(make_chunk_done_html(i, total, chunk, wav, sr)) |
| yield all_audio, html_blocks, f"Xong đoạn {i}/{total}", i |
| except Exception as e: |
| html_blocks.append(make_chunk_error_html(i, total, str(e))) |
| yield all_audio, html_blocks, f"Lỗi đoạn {i}: {e}", i |
|
|
| yield all_audio, html_blocks, f"✅ Hoàn tất {total} đoạn.", total |
|
|
|
|
| def generate_voice_design_chunked(text, language, voice_description, chunk_size): |
| """Tạo giọng nói theo từng chunk - Voice Design (1.7B).""" |
| if not text or not text.strip(): |
| yield None, "Lỗi: Văn bản là bắt buộc.", wrap_chunk_area("<p style='color:red'>Lỗi: Văn bản là bắt buộc.</p>") |
| return |
| if not voice_description or not voice_description.strip(): |
| yield None, "Lỗi: Mô tả giọng nói là bắt buộc.", wrap_chunk_area("<p style='color:red'>Lỗi: Mô tả giọng nói là bắt buộc.</p>") |
| return |
|
|
| chunks = split_into_chunks(text.strip(), max_chars=int(chunk_size)) |
| total = len(chunks) |
| lang_en = LANGUAGE_MAP.get(language, "Auto") |
|
|
| yield None, f"Đang tải mô hình... ({total} đoạn)", wrap_chunk_area("<p style='color:#6b7280; padding:8px;'>Đang tải mô hình...</p>") |
|
|
| try: |
| model = get_model("voice_design_1.7B", "VoiceDesign", "1.7B") |
| except Exception as e: |
| yield None, f"Lỗi tải mô hình: {e}", wrap_chunk_area(f"<p style='color:red'>Lỗi tải mô hình: {e}</p>") |
| return |
|
|
| def gen(chunk): |
| wavs, sr = model.generate_voice_design( |
| text=chunk, language=lang_en, |
| instruct=voice_description.strip(), |
| non_streaming_mode=True, max_new_tokens=2048, |
| ) |
| return wavs[0], sr |
|
|
| for all_audio, html_blocks, status, _ in _run_chunked(chunks, gen, total): |
| out_audio = None |
| if all_audio: |
| combined_wav, combined_sr = concat_audio(all_audio) |
| out_audio = (combined_sr, combined_wav) |
| yield out_audio, status, wrap_chunk_area("".join(html_blocks)) |
|
|
|
|
| def generate_voice_clone_chunked(ref_audio, ref_text, target_text, language, use_xvector_only, model_size, chunk_size): |
| """Tạo giọng nói theo từng chunk - Voice Clone (Base).""" |
| if not target_text or not target_text.strip(): |
| yield None, "Lỗi: Văn bản cần đọc là bắt buộc.", wrap_chunk_area("<p style='color:red'>Lỗi: Văn bản cần đọc là bắt buộc.</p>") |
| return |
|
|
| audio_tuple = _audio_to_tuple(ref_audio) |
| if audio_tuple is None: |
| yield None, "Lỗi: Âm thanh tham chiếu là bắt buộc.", wrap_chunk_area("<p style='color:red'>Lỗi: Âm thanh tham chiếu là bắt buộc.</p>") |
| return |
|
|
| if not use_xvector_only and (not ref_text or not ref_text.strip()): |
| yield None, "Lỗi: Văn bản tham chiếu là bắt buộc.", wrap_chunk_area("<p style='color:red'>Lỗi: Văn bản tham chiếu là bắt buộc.</p>") |
| return |
|
|
| chunks = split_into_chunks(target_text.strip(), max_chars=int(chunk_size)) |
| total = len(chunks) |
| lang_en = LANGUAGE_MAP.get(language, "Auto") |
|
|
| yield None, f"Đang tải mô hình... ({total} đoạn)", wrap_chunk_area("<p style='color:#6b7280; padding:8px;'>Đang tải mô hình...</p>") |
|
|
| try: |
| model = get_model(f"base_{model_size}", "Base", model_size) |
| except Exception as e: |
| yield None, f"Lỗi tải mô hình: {e}", wrap_chunk_area(f"<p style='color:red'>Lỗi tải mô hình: {e}</p>") |
| return |
|
|
| def gen(chunk): |
| wavs, sr = model.generate_voice_clone( |
| text=chunk, language=lang_en, |
| ref_audio=audio_tuple, |
| ref_text=ref_text.strip() if ref_text else None, |
| x_vector_only_mode=use_xvector_only, |
| max_new_tokens=2048, |
| ) |
| return wavs[0], sr |
|
|
| for all_audio, html_blocks, status, _ in _run_chunked(chunks, gen, total): |
| out_audio = None |
| if all_audio: |
| combined_wav, combined_sr = concat_audio(all_audio) |
| out_audio = (combined_sr, combined_wav) |
| yield out_audio, status, wrap_chunk_area("".join(html_blocks)) |
|
|
|
|
| def generate_custom_voice_chunked(text, language, speaker, instruct, model_size, chunk_size): |
| """Tạo giọng nói theo từng chunk - CustomVoice.""" |
| if not text or not text.strip(): |
| yield None, "Lỗi: Văn bản là bắt buộc.", wrap_chunk_area("<p style='color:red'>Lỗi: Văn bản là bắt buộc.</p>") |
| return |
| if not speaker: |
| yield None, "Lỗi: Giọng đọc là bắt buộc.", wrap_chunk_area("<p style='color:red'>Lỗi: Giọng đọc là bắt buộc.</p>") |
| return |
|
|
| chunks = split_into_chunks(text.strip(), max_chars=int(chunk_size)) |
| total = len(chunks) |
| lang_en = LANGUAGE_MAP.get(language, "Auto") |
|
|
| yield None, f"Đang tải mô hình... ({total} đoạn)", wrap_chunk_area("<p style='color:#6b7280; padding:8px;'>Đang tải mô hình...</p>") |
|
|
| try: |
| model = get_model(f"custom_voice_{model_size}", "CustomVoice", model_size) |
| except Exception as e: |
| yield None, f"Lỗi tải mô hình: {e}", wrap_chunk_area(f"<p style='color:red'>Lỗi tải mô hình: {e}</p>") |
| return |
|
|
| def gen(chunk): |
| wavs, sr = model.generate_custom_voice( |
| text=chunk, language=lang_en, |
| speaker=speaker.lower().replace(" ", "_"), |
| instruct=instruct.strip() if instruct else None, |
| non_streaming_mode=True, max_new_tokens=2048, |
| ) |
| return wavs[0], sr |
|
|
| for all_audio, html_blocks, status, _ in _run_chunked(chunks, gen, total): |
| out_audio = None |
| if all_audio: |
| combined_wav, combined_sr = concat_audio(all_audio) |
| out_audio = (combined_sr, combined_wav) |
| yield out_audio, status, wrap_chunk_area("".join(html_blocks)) |
|
|
|
|
| |
| |
| |
|
|
| def _chunk_controls(): |
| """Controls cài đặt chia đoạn tái sử dụng.""" |
| with gr.Accordion("⚙️ Cài đặt chia đoạn", open=False): |
| chunk_size = gr.Slider( |
| label="Số ký tự tối đa mỗi đoạn", |
| minimum=50, maximum=500, value=200, step=10, |
| info="Văn bản tự động chia ở dấu câu gần nhất trước giới hạn này. Số đoạn không giới hạn." |
| ) |
| return chunk_size |
|
|
|
|
| def _chunk_output_area(tab_id: str): |
| """Output area: audio tổng hợp + status + HTML preview từng chunk.""" |
| audio_out = gr.Audio( |
| label="🔊 Audio tổng hợp — cập nhật liên tục sau mỗi đoạn", |
| type="numpy" |
| ) |
| status = gr.Textbox(label="Trạng thái", lines=1, interactive=False) |
| gr.Markdown("#### 📦 Tiến trình & Preview từng đoạn") |
| chunk_html = gr.HTML( |
| value="<p style='color:#9ca3af; font-style:italic; padding:8px;'>Nhấn ▶ để bắt đầu...</p>" |
| ) |
| return audio_out, status, chunk_html |
|
|
|
|
| def build_ui(): |
| theme = gr.themes.Soft( |
| font=[gr.themes.GoogleFont("Source Sans Pro"), "Arial", "sans-serif"], |
| ) |
| css = ".gradio-container {max-width: none !important;}" |
|
|
| with gr.Blocks(theme=theme, css=css, title="Qwen3-TTS Demo (CPU)") as demo: |
| gr.Markdown(""" |
| # 🎙️ Demo Chuyển Văn Bản Thành Giọng Nói (CPU) |
| Hỗ trợ văn bản **không giới hạn độ dài** — tự động chia chunk theo câu/đoạn, xử lý tuần tự, ghép audio và hiển thị preview từng chunk ngay khi hoàn tất. |
| |
| ⚠️ **Chế độ CPU**: Quá trình tạo có thể chậm. Audio tổng hợp sẽ được cập nhật dần sau mỗi đoạn — bạn có thể nghe ngay trong khi các đoạn sau vẫn đang xử lý. |
| """) |
|
|
| with gr.Tabs(): |
|
|
| |
| |
| |
| with gr.Tab("🎨 Thiết kế giọng nói"): |
| gr.Markdown("### Tạo giọng nói tùy chỉnh bằng mô tả ngôn ngữ tự nhiên *(mô hình 1.7B)*") |
| with gr.Row(): |
| with gr.Column(scale=3): |
| d_text = gr.Textbox( |
| label="Văn bản cần đọc (không giới hạn độ dài)", |
| lines=7, |
| placeholder="Nhập văn bản dài tùy ý. Hệ thống sẽ tự động chia thành các đoạn nhỏ...", |
| value=( |
| "Trong ngăn kéo trên... khoan đã, trống rỗng? Không thể nào, " |
| "tôi chắc chắn đã để ở đó mà! Đây không phải lần đầu tiên điều này xảy ra. " |
| "Hôm qua tôi cũng không tìm thấy chìa khóa của mình. " |
| "Thứ Hai tuần trước thì cái bút lại biến mất. " |
| "Có gì đó không ổn trong căn phòng này." |
| ) |
| ) |
| d_language = gr.Dropdown( |
| label="Ngôn ngữ", choices=LANGUAGES, value="Tự động", interactive=True |
| ) |
| d_instruct = gr.Textbox( |
| label="Mô tả giọng nói", |
| lines=2, |
| placeholder="Ví dụ: Giọng ngạc nhiên, lo lắng, bắt đầu hoảng loạn...", |
| value="Giọng ngạc nhiên, không tin tưởng, bắt đầu có chút hoảng loạn." |
| ) |
| d_chunk_size = _chunk_controls() |
| with gr.Row(): |
| d_preview_btn = gr.Button("🔍 Xem trước các đoạn", variant="secondary") |
| d_gen_btn = gr.Button("▶ Tạo giọng nói", variant="primary", scale=2) |
|
|
| with gr.Column(scale=2): |
| d_audio_out, d_status, d_chunk_html = _chunk_output_area("design") |
|
|
| d_preview_btn.click( |
| fn=lambda t, cs: preview_chunks(t, cs), |
| inputs=[d_text, d_chunk_size], |
| outputs=[d_chunk_html], |
| ) |
| d_gen_btn.click( |
| fn=generate_voice_design_chunked, |
| inputs=[d_text, d_language, d_instruct, d_chunk_size], |
| outputs=[d_audio_out, d_status, d_chunk_html], |
| ) |
|
|
| |
| |
| |
| with gr.Tab("🎤 Nhân bản giọng nói"): |
| gr.Markdown("### Sao chép giọng nói từ file âm thanh tham chiếu") |
| with gr.Row(): |
| with gr.Column(scale=2): |
| c_ref_audio = gr.Audio( |
| label="Âm thanh tham chiếu (tải lên mẫu giọng nói cần sao chép)", |
| type="filepath" |
| ) |
| c_ref_text = gr.Textbox( |
| label="Văn bản tham chiếu (nội dung đọc trong audio trên)", |
| lines=2, |
| placeholder="Nhập chính xác nội dung được đọc trong file âm thanh tham chiếu...", |
| ) |
| c_xvector = gr.Checkbox( |
| label="Chỉ dùng x-vector (không cần văn bản tham chiếu, chất lượng thấp hơn)", |
| value=False, |
| ) |
| c_target_text = gr.Textbox( |
| label="Văn bản cần đọc (không giới hạn độ dài)", |
| lines=6, |
| placeholder="Nhập văn bản dài tùy ý. Hệ thống sẽ tự động chia thành các đoạn nhỏ...", |
| ) |
| with gr.Row(): |
| c_language = gr.Dropdown( |
| label="Ngôn ngữ", choices=LANGUAGES, value="Tự động", interactive=True |
| ) |
| c_model_size = gr.Dropdown( |
| label="Kích thước mô hình", choices=MODEL_SIZES, value="0.6B", interactive=True |
| ) |
| c_chunk_size = _chunk_controls() |
| with gr.Row(): |
| c_preview_btn = gr.Button("🔍 Xem trước các đoạn", variant="secondary") |
| c_gen_btn = gr.Button("▶ Nhân bản & Tạo", variant="primary", scale=2) |
|
|
| with gr.Column(scale=2): |
| c_audio_out, c_status, c_chunk_html = _chunk_output_area("clone") |
|
|
| c_preview_btn.click( |
| fn=lambda t, cs: preview_chunks(t, cs), |
| inputs=[c_target_text, c_chunk_size], |
| outputs=[c_chunk_html], |
| ) |
| c_gen_btn.click( |
| fn=generate_voice_clone_chunked, |
| inputs=[c_ref_audio, c_ref_text, c_target_text, c_language, c_xvector, c_model_size, c_chunk_size], |
| outputs=[c_audio_out, c_status, c_chunk_html], |
| ) |
|
|
| |
| |
| |
| with gr.Tab("🗣️ TTS (CustomVoice)"): |
| gr.Markdown("### Chuyển văn bản thành giọng nói với các giọng đọc có sẵn") |
| with gr.Row(): |
| with gr.Column(scale=3): |
| t_text = gr.Textbox( |
| label="Văn bản cần đọc (không giới hạn độ dài)", |
| lines=7, |
| placeholder="Nhập văn bản dài tùy ý. Hệ thống sẽ tự động chia thành các đoạn nhỏ...", |
| value=( |
| "Xin chào! Chào mừng bạn đến với hệ thống chuyển văn bản thành giọng nói. " |
| "Đây là bản demo các tính năng TTS. " |
| "Bạn có thể nhập văn bản dài tùy ý và hệ thống sẽ tự động chia thành các đoạn nhỏ để xử lý tuần tự. " |
| "Mỗi đoạn hoàn tất sẽ xuất hiện ngay bên dưới với player riêng để bạn nghe thử." |
| ) |
| ) |
| with gr.Row(): |
| t_language = gr.Dropdown( |
| label="Ngôn ngữ", choices=LANGUAGES, value="Tiếng Anh", interactive=True |
| ) |
| t_speaker = gr.Dropdown( |
| label="Giọng đọc", choices=SPEAKERS, value="Ryan", interactive=True |
| ) |
| t_instruct = gr.Textbox( |
| label="Hướng dẫn phong cách (tùy chọn)", |
| lines=2, |
| placeholder="Ví dụ: Đọc với giọng vui vẻ và năng động", |
| ) |
| t_model_size = gr.Dropdown( |
| label="Kích thước mô hình", choices=MODEL_SIZES, value="0.6B", interactive=True |
| ) |
| t_chunk_size = _chunk_controls() |
| with gr.Row(): |
| t_preview_btn = gr.Button("🔍 Xem trước các đoạn", variant="secondary") |
| t_gen_btn = gr.Button("▶ Tạo giọng nói", variant="primary", scale=2) |
|
|
| with gr.Column(scale=2): |
| t_audio_out, t_status, t_chunk_html = _chunk_output_area("tts") |
|
|
| t_preview_btn.click( |
| fn=lambda t, cs: preview_chunks(t, cs), |
| inputs=[t_text, t_chunk_size], |
| outputs=[t_chunk_html], |
| ) |
| t_gen_btn.click( |
| fn=generate_custom_voice_chunked, |
| inputs=[t_text, t_language, t_speaker, t_instruct, t_model_size, t_chunk_size], |
| outputs=[t_audio_out, t_status, t_chunk_html], |
| ) |
|
|
| gr.Markdown(""" |
| --- |
| **Lưu ý**: Mỗi đoạn xử lý xong sẽ tự động xuất hiện bên dưới với player riêng. |
| Audio tổng hợp (trên cùng) được ghép và cập nhật liên tục — bạn có thể nghe trong khi các đoạn sau vẫn đang chạy. |
| Nên dùng mô hình 0.6B và đoạn ≤200 ký tự để tốc độ tối ưu. |
| """) |
|
|
| return demo |
|
|
|
|
| if __name__ == "__main__": |
| print("Đang khởi động Demo TTS (Phiên bản CPU)...") |
| print("Các mô hình sẽ được tải theo yêu cầu để tiết kiệm bộ nhớ.") |
| demo = build_ui() |
| demo.launch(server_name="0.0.0.0", server_port=7860) |