Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| import soundfile as sf | |
| import tempfile | |
| import os | |
| import time | |
| import numpy as np | |
| import librosa | |
| import re | |
| import json | |
| import shutil | |
| from pathlib import Path | |
| from datetime import datetime | |
| from llama_cpp import Llama | |
| from neucodec import NeuCodecOnnxDecoder | |
| import torch | |
| from utils.phonemize_text import phonemize_with_dict | |
| import threading | |
| from queue import Queue | |
| from dataclasses import dataclass, asdict | |
| from typing import Optional, Dict, List, Tuple | |
| import hashlib | |
| print("⏳ Đang khởi động VieNeu-TTS Enhanced v3.0...") | |
| # --- CONSTANTS --- | |
| MAX_CHARS_PER_CHUNK = 256 | |
| SAMPLE_RATE = 24000 | |
| DEVICE_INFO = "Q4 GGUF (llama-cpp) + ONNX Codec" | |
| VERSION = "3.0 Enhanced" | |
| # --- DATA CLASSES --- | |
| class VoiceSettings: | |
| """Cài đặt tùy chỉnh giọng nói""" | |
| temperature: float = 1.0 | |
| top_k: int = 50 | |
| top_p: float = 0.95 | |
| speed_ratio: float = 1.0 # 0.5-2.0 | |
| pitch_shift: int = 0 # -12 to +12 semitones | |
| volume_gain: float = 1.0 # 0.5-2.0 | |
| silence_duration: float = 0.15 # seconds between chunks | |
| def to_dict(self): | |
| return asdict(self) | |
| class HistoryRecord: | |
| """Bản ghi lịch sử""" | |
| id: str | |
| timestamp: str | |
| text: str | |
| full_text: str | |
| voice: str | |
| audio_path: Optional[str] | |
| duration: float | |
| status: str | |
| settings: Dict | |
| text_hash: str | |
| def to_dict(self): | |
| return asdict(self) | |
| # Thư mục lưu lịch sử | |
| try: | |
| HISTORY_DIR = "./tts_history" | |
| os.makedirs(HISTORY_DIR, exist_ok=True) | |
| test_file = os.path.join(HISTORY_DIR, ".test") | |
| with open(test_file, 'w') as f: | |
| f.write("test") | |
| os.remove(test_file) | |
| except (PermissionError, OSError): | |
| HISTORY_DIR = os.path.join(tempfile.gettempdir(), "vieneu_tts_history") | |
| os.makedirs(HISTORY_DIR, exist_ok=True) | |
| print(f"⚠️ Không có quyền ghi, sử dụng thư mục tạm: {HISTORY_DIR}") | |
| HISTORY_JSON = os.path.join(HISTORY_DIR, "history.json") | |
| SETTINGS_DIR = os.path.join(HISTORY_DIR, "presets") | |
| os.makedirs(SETTINGS_DIR, exist_ok=True) | |
| # Đường dẫn model | |
| BACKBONE_REPO = "pnnbao-ump/VieNeu-TTS-q8-gguf" | |
| CODEC_REPO = "neuphonic/neucodec-onnx-decoder" | |
| # Giọng mẫu | |
| VOICE_SAMPLES = { | |
| "Tuyên (nam miền Bắc)": { | |
| "audio": "./sample/Tuyên (nam miền Bắc).wav", | |
| "text": "./sample/Tuyên (nam miền Bắc).txt", | |
| "codes": "./sample/Tuyên (nam miền Bắc).pt" | |
| }, | |
| "Vĩnh (nam miền Nam)": { | |
| "audio": "./sample/Vĩnh (nam miền Nam).wav", | |
| "text": "./sample/Vĩnh (nam miền Nam).txt", | |
| "codes": "./sample/Vĩnh (nam miền Nam).pt" | |
| }, | |
| "Bình (nam miền Bắc)": { | |
| "audio": "./sample/Bình (nam miền Bắc).wav", | |
| "text": "./sample/Bình (nam miền Bắc).txt", | |
| "codes": "./sample/Bình (nam miền Bắc).pt" | |
| }, | |
| "Nguyên (nam miền Nam)": { | |
| "audio": "./sample/Nguyên (nam miền Nam).wav", | |
| "text": "./sample/Nguyên (nam miền Nam).txt", | |
| "codes": "./sample/Nguyên (nam miền Nam).pt" | |
| }, | |
| "Sơn (nam miền Nam)": { | |
| "audio": "./sample/Sơn (nam miền Nam).wav", | |
| "text": "./sample/Sơn (nam miền Nam).txt", | |
| "codes": "./sample/Sơn (nam miền Nam).pt" | |
| }, | |
| "Đoan (nữ miền Nam)": { | |
| "audio": "./sample/Đoan (nữ miền Nam).wav", | |
| "text": "./sample/Đoan (nữ miền Nam).txt", | |
| "codes": "./sample/Đoan (nữ miền Nam).pt" | |
| }, | |
| "Ngọc (nữ miền Bắc)": { | |
| "audio": "./sample/Ngọc (nữ miền Bắc).wav", | |
| "text": "./sample/Ngọc (nữ miền Bắc).txt", | |
| "codes": "./sample/Ngọc (nữ miền Bắc).pt" | |
| }, | |
| "Ly (nữ miền Bắc)": { | |
| "audio": "./sample/Ly (nữ miền Bắc).wav", | |
| "text": "./sample/Ly (nữ miền Bắc).txt", | |
| "codes": "./sample/Ly (nữ miền Bắc).pt" | |
| }, | |
| "Dung (nữ miền Nam)": { | |
| "audio": "./sample/Dung (nữ miền Nam).wav", | |
| "text": "./sample/Dung (nữ miền Nam).txt", | |
| "codes": "./sample/Dung (nữ miền Nam).pt" | |
| } | |
| } | |
| # --- PRESET MANAGEMENT --- | |
| DEFAULT_PRESETS = { | |
| "Mặc định": VoiceSettings(), | |
| "Giọng nhanh": VoiceSettings(speed_ratio=1.3, silence_duration=0.1), | |
| "Giọng chậm": VoiceSettings(speed_ratio=0.8, silence_duration=0.2), | |
| "Giọng trầm": VoiceSettings(pitch_shift=-3), | |
| "Giọng cao": VoiceSettings(pitch_shift=3), | |
| "Nhiệt tình": VoiceSettings(temperature=1.2, volume_gain=1.2), | |
| "Bình tĩnh": VoiceSettings(temperature=0.8, volume_gain=0.9, speed_ratio=0.9), | |
| } | |
| history_lock = threading.Lock() | |
| settings_lock = threading.Lock() | |
| def load_presets() -> Dict[str, VoiceSettings]: | |
| """Tải các preset đã lưu""" | |
| presets = DEFAULT_PRESETS.copy() | |
| try: | |
| preset_files = Path(SETTINGS_DIR).glob("*.json") | |
| for file in preset_files: | |
| with open(file, 'r', encoding='utf-8') as f: | |
| data = json.load(f) | |
| name = file.stem | |
| presets[name] = VoiceSettings(**data) | |
| except Exception as e: | |
| print(f"⚠️ Lỗi tải preset: {e}") | |
| return presets | |
| def save_preset(name: str, settings: VoiceSettings): | |
| """Lưu preset""" | |
| with settings_lock: | |
| try: | |
| preset_path = os.path.join(SETTINGS_DIR, f"{name}.json") | |
| with open(preset_path, 'w', encoding='utf-8') as f: | |
| json.dump(settings.to_dict(), f, indent=2) | |
| return True, f"✅ Đã lưu preset '{name}'" | |
| except Exception as e: | |
| return False, f"❌ Lỗi lưu preset: {e}" | |
| def delete_preset(name: str): | |
| """Xóa preset""" | |
| if name in DEFAULT_PRESETS: | |
| return False, "❌ Không thể xóa preset mặc định" | |
| with settings_lock: | |
| try: | |
| preset_path = os.path.join(SETTINGS_DIR, f"{name}.json") | |
| if os.path.exists(preset_path): | |
| os.remove(preset_path) | |
| return True, f"✅ Đã xóa preset '{name}'" | |
| return False, "❌ Preset không tồn tại" | |
| except Exception as e: | |
| return False, f"❌ Lỗi xóa preset: {e}" | |
| # --- HISTORY MANAGEMENT --- | |
| def load_history() -> List[HistoryRecord]: | |
| """Tải lịch sử từ file JSON""" | |
| with history_lock: | |
| if os.path.exists(HISTORY_JSON): | |
| try: | |
| with open(HISTORY_JSON, 'r', encoding='utf-8') as f: | |
| data = json.load(f) | |
| return [HistoryRecord(**item) for item in data] | |
| except Exception as e: | |
| print(f"⚠️ Lỗi đọc history.json: {e}") | |
| return [] | |
| return [] | |
| def save_history(history: List[HistoryRecord]): | |
| """Lưu lịch sử vào file JSON""" | |
| with history_lock: | |
| try: | |
| data = [record.to_dict() for record in history] | |
| with open(HISTORY_JSON, 'w', encoding='utf-8') as f: | |
| json.dump(data, f, ensure_ascii=False, indent=2) | |
| except Exception as e: | |
| print(f"⚠️ Lỗi ghi history.json: {e}") | |
| def get_text_hash(text: str) -> str: | |
| """Tạo hash cho text để tránh trùng lặp""" | |
| return hashlib.md5(text.encode('utf-8')).hexdigest()[:8] | |
| def add_to_history(text: str, voice: str, audio_path: Optional[str], | |
| duration: float, status: str, settings: VoiceSettings) -> Optional[str]: | |
| """Thêm bản ghi vào lịch sử""" | |
| try: | |
| history = load_history() | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") | |
| filename = f"tts_{timestamp}.wav" | |
| permanent_path = os.path.join(HISTORY_DIR, filename) | |
| if audio_path and os.path.exists(audio_path): | |
| try: | |
| shutil.copy2(audio_path, permanent_path) | |
| except Exception as e: | |
| print(f"⚠️ Không thể copy file audio: {e}") | |
| permanent_path = audio_path | |
| else: | |
| permanent_path = None | |
| record = HistoryRecord( | |
| id=timestamp, | |
| timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), | |
| text=text[:100] + "..." if len(text) > 100 else text, | |
| full_text=text, | |
| voice=voice, | |
| audio_path=permanent_path, | |
| duration=duration, | |
| status=status, | |
| settings=settings.to_dict(), | |
| text_hash=get_text_hash(text) | |
| ) | |
| history.insert(0, record) | |
| # Giới hạn 100 bản ghi | |
| if len(history) > 100: | |
| old_record = history.pop() | |
| try: | |
| if old_record.audio_path and os.path.exists(old_record.audio_path): | |
| os.remove(old_record.audio_path) | |
| except Exception as e: | |
| print(f"⚠️ Không thể xóa file cũ: {e}") | |
| save_history(history) | |
| return permanent_path | |
| except Exception as e: | |
| print(f"⚠️ Lỗi khi lưu lịch sử: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| return audio_path if audio_path else None | |
| def get_history_list(filter_voice: str = "Tất cả", search_text: str = "") -> str: | |
| """Tạo HTML hiển thị lịch sử với filter""" | |
| history = load_history() | |
| # Filter | |
| if filter_voice != "Tất cả": | |
| history = [r for r in history if r.voice == filter_voice] | |
| if search_text: | |
| history = [r for r in history if search_text.lower() in r.full_text.lower()] | |
| if not history: | |
| return """ | |
| <div style='padding: 20px; text-align: center; color: #64748b;'> | |
| <p style='font-size: 1.1em;'>📭 Không tìm thấy bản ghi nào</p> | |
| </div> | |
| """ | |
| html_parts = ["<div style='font-family: system-ui; line-height: 1.6;'>"] | |
| for i, record in enumerate(history[:50], 1): | |
| status_color = "#10b981" if record.status == "Thành công" else "#ef4444" | |
| status_icon = "✅" if record.status == "Thành công" else "❌" | |
| # Settings summary | |
| settings_html = f""" | |
| <div style='font-size: 0.75em; color: #94a3b8; margin-top: 4px;'> | |
| 🎛️ Temp:{record.settings.get('temperature', 1.0):.1f} | | |
| Speed:{record.settings.get('speed_ratio', 1.0):.1f}x | | |
| Pitch:{record.settings.get('pitch_shift', 0):+d} | |
| </div> | |
| """ | |
| html_parts.append(f""" | |
| <div style=' | |
| background: white; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 8px; | |
| padding: 15px; | |
| margin-bottom: 12px; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.1); | |
| transition: all 0.2s; | |
| ' onmouseover="this.style.boxShadow='0 4px 6px rgba(0,0,0,0.15)'" | |
| onmouseout="this.style.boxShadow='0 1px 3px rgba(0,0,0,0.1)'"> | |
| <div style='display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;'> | |
| <div style='font-weight: 600; color: #1e293b; font-size: 0.95em;'> | |
| <span style='color: #64748b;'>#{i}</span> {record.voice} | |
| {settings_html} | |
| </div> | |
| <div style='font-size: 0.85em; color: #64748b;'> | |
| {record.timestamp} | |
| </div> | |
| </div> | |
| <div style=' | |
| background: #f8fafc; | |
| padding: 10px; | |
| border-radius: 6px; | |
| margin-bottom: 8px; | |
| color: #334155; | |
| font-size: 0.9em; | |
| border-left: 3px solid #3b82f6; | |
| '> | |
| {record.text} | |
| </div> | |
| <div style='display: flex; gap: 20px; font-size: 0.85em; color: #64748b;'> | |
| <div>⏱️ {record.duration:.2f}s</div> | |
| <div style='color: {status_color}; font-weight: 500;'> | |
| {status_icon} {record.status} | |
| </div> | |
| <div style='margin-left: auto; color: #3b82f6; cursor: pointer;' title='{record.id}'> | |
| ID: {record.id[:13]}... | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| html_parts.append("</div>") | |
| return "".join(html_parts) | |
| def clear_all_history(): | |
| """Xóa toàn bộ lịch sử""" | |
| history = load_history() | |
| for record in history: | |
| try: | |
| if record.audio_path and os.path.exists(record.audio_path): | |
| os.remove(record.audio_path) | |
| except Exception as e: | |
| print(f"⚠️ Không thể xóa file: {e}") | |
| save_history([]) | |
| return "✅ Đã xóa toàn bộ lịch sử" | |
| # --- BACKGROUND PROCESSING --- | |
| processing_queue = Queue() | |
| is_processing = False | |
| processing_stats = {"total": 0, "success": 0, "failed": 0} | |
| stats_lock = threading.Lock() | |
| def update_stats(success: bool): | |
| """Cập nhật thống kê thread-safe""" | |
| with stats_lock: | |
| processing_stats["total"] += 1 | |
| if success: | |
| processing_stats["success"] += 1 | |
| else: | |
| processing_stats["failed"] += 1 | |
| def get_processing_stats() -> str: | |
| """Lấy thống kê xử lý""" | |
| with stats_lock: | |
| return (f"📊 Tổng: {processing_stats['total']} | " | |
| f"✅ Thành công: {processing_stats['success']} | " | |
| f"❌ Thất bại: {processing_stats['failed']}") | |
| # --- AUDIO PROCESSING --- | |
| def apply_audio_effects(audio: np.ndarray, settings: VoiceSettings) -> np.ndarray: | |
| """Áp dụng hiệu ứng audio""" | |
| try: | |
| # Speed change | |
| if settings.speed_ratio != 1.0: | |
| audio = librosa.effects.time_stretch(audio, rate=settings.speed_ratio) | |
| # Pitch shift | |
| if settings.pitch_shift != 0: | |
| audio = librosa.effects.pitch_shift( | |
| audio, | |
| sr=SAMPLE_RATE, | |
| n_steps=settings.pitch_shift | |
| ) | |
| # Volume | |
| if settings.volume_gain != 1.0: | |
| audio = audio * settings.volume_gain | |
| audio = np.clip(audio, -1.0, 1.0) | |
| return audio | |
| except Exception as e: | |
| print(f"⚠️ Lỗi áp dụng hiệu ứng: {e}") | |
| return audio | |
| def split_text_into_chunks(text: str, max_chars: int = 256) -> List[str]: | |
| """Chia text thành chunks thông minh""" | |
| sentences = re.split(r'([.!?,;])', text) | |
| chunks = [] | |
| current = "" | |
| for i in range(0, len(sentences), 2): | |
| sentence = sentences[i] | |
| punct = sentences[i+1] if i+1 < len(sentences) else "" | |
| segment = sentence + punct | |
| if len(current) + len(segment) <= max_chars: | |
| current += segment | |
| else: | |
| if current: | |
| chunks.append(current.strip()) | |
| current = segment | |
| if current: | |
| chunks.append(current.strip()) | |
| return chunks if chunks else [text] | |
| def decode_audio(codes_str: str, codec) -> np.ndarray: | |
| """Decode speech tokens to audio""" | |
| speech_ids = [int(num) for num in re.findall(r"<\|speech_(\d+)\|>", codes_str)] | |
| if len(speech_ids) == 0: | |
| print("Không tìm thấy mã giọng nói hợp lệ.") | |
| return np.array([], dtype=np.float32) | |
| codes = np.array(speech_ids, dtype=np.int32)[np.newaxis, np.newaxis, :] | |
| recon = codec.decode_code(codes) | |
| return recon[0, 0, :] | |
| # --- MODEL LOADING --- | |
| print("📦 Đang tải model Q4 GGUF và Codec ONNX...") | |
| model_loaded = False | |
| backbone = None | |
| codec = None | |
| try: | |
| backbone = Llama.from_pretrained( | |
| repo_id=BACKBONE_REPO, | |
| filename="*.gguf", | |
| verbose=False, | |
| n_gpu_layers=-1, | |
| n_ctx=2048, | |
| mlock=True, | |
| flash_attn=True, | |
| ) | |
| codec = NeuCodecOnnxDecoder.from_pretrained(CODEC_REPO) | |
| print("✅ Model đã tải thành công!") | |
| model_loaded = True | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| print(f"❌ Lỗi khi tải model: {e}") | |
| model_loaded = False | |
| # --- SYNTHESIS FUNCTIONS --- | |
| def synthesize_speech_internal(text: str, voice_choice: str, | |
| settings: VoiceSettings) -> Optional[str]: | |
| """Internal synthesis function""" | |
| global backbone, codec, model_loaded | |
| if not model_loaded or not text.strip() or voice_choice not in VOICE_SAMPLES: | |
| return None | |
| raw_text = text.strip() | |
| # Load reference | |
| ref_text_path = VOICE_SAMPLES[voice_choice]["text"] | |
| try: | |
| with open(ref_text_path, "r", encoding="utf-8") as f: | |
| ref_text_raw = f.read() | |
| except Exception as e: | |
| print(f"❌ Lỗi đọc file text mẫu: {e}") | |
| return None | |
| # Load codes | |
| codes_path = VOICE_SAMPLES[voice_choice]["codes"] | |
| try: | |
| ref_codes_tensor = torch.load(codes_path, map_location="cpu") | |
| if isinstance(ref_codes_tensor, torch.Tensor): | |
| ref_codes = ref_codes_tensor.cpu().numpy() | |
| else: | |
| ref_codes = np.array(ref_codes_tensor) | |
| except Exception as e: | |
| print(f"❌ Lỗi khi tải codes: {e}") | |
| return None | |
| if ref_codes is None or len(ref_codes) == 0: | |
| return None | |
| # Split text | |
| text_chunks = split_text_into_chunks(raw_text, max_chars=MAX_CHARS_PER_CHUNK) | |
| all_audio_segments = [] | |
| silence_pad = np.zeros(int(SAMPLE_RATE * settings.silence_duration), dtype=np.float32) | |
| start_time = time.time() | |
| try: | |
| for i, chunk in enumerate(text_chunks): | |
| print(f"[Internal] Xử lý chunk {i+1}/{len(text_chunks)}") | |
| # Phonemize | |
| ref_text_phoneme = phonemize_with_dict(ref_text_raw) | |
| input_text_phoneme = phonemize_with_dict(chunk) | |
| # Create prompt | |
| codes_str = "".join([f"<|speech_{idx}|>" for idx in ref_codes]) | |
| prompt = ( | |
| f"user: Convert the text to speech:<|TEXT_PROMPT_START|>{ref_text_phoneme} {input_text_phoneme}" | |
| f"<|TEXT_PROMPT_END|>\nassistant:<|SPEECH_GENERATION_START|>{codes_str}" | |
| ) | |
| # Generate | |
| output = backbone( | |
| prompt, | |
| max_tokens=2048, | |
| temperature=settings.temperature, | |
| top_k=settings.top_k, | |
| top_p=settings.top_p, | |
| stop=["<|SPEECH_GENERATION_END|>"], | |
| ) | |
| output_str = output["choices"][0]["text"] | |
| # Decode | |
| chunk_wav = decode_audio(output_str, codec) | |
| # Apply effects | |
| chunk_wav = apply_audio_effects(chunk_wav, settings) | |
| if chunk_wav is not None and len(chunk_wav) > 0: | |
| all_audio_segments.append(chunk_wav) | |
| if i < len(text_chunks) - 1: | |
| all_audio_segments.append(silence_pad) | |
| if not all_audio_segments: | |
| add_to_history(raw_text, voice_choice, None, 0, "Lỗi: Không sinh được audio", settings) | |
| return None | |
| final_wav = np.concatenate(all_audio_segments) | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp: | |
| sf.write(tmp.name, final_wav, SAMPLE_RATE) | |
| output_path = tmp.name | |
| process_time = time.time() - start_time | |
| permanent_path = add_to_history(raw_text, voice_choice, output_path, process_time, "Thành công", settings) | |
| update_stats(True) | |
| print(f"✅ Hoàn thành: {permanent_path}") | |
| return permanent_path | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| add_to_history(raw_text, voice_choice, None, 0, f"Lỗi: {str(e)}", settings) | |
| update_stats(False) | |
| return None | |
| def synthesize_speech(text: str, voice_choice: str, | |
| temperature: float, top_k: int, top_p: float, | |
| speed_ratio: float, pitch_shift: int, volume_gain: float, | |
| silence_duration: float): | |
| """Main synthesis function với UI feedback""" | |
| global backbone, codec, model_loaded | |
| if not model_loaded: | |
| yield None, "⚠️ Model chưa tải. Vui lòng kiểm tra lỗi console!" | |
| return | |
| if not text or text.strip() == "": | |
| yield None, "⚠️ Vui lòng nhập văn bản!" | |
| return | |
| if voice_choice not in VOICE_SAMPLES: | |
| yield None, "⚠️ Vui lòng chọn giọng mẫu." | |
| return | |
| # Create settings | |
| settings = VoiceSettings( | |
| temperature=temperature, | |
| top_k=top_k, | |
| top_p=top_p, | |
| speed_ratio=speed_ratio, | |
| pitch_shift=pitch_shift, | |
| volume_gain=volume_gain, | |
| silence_duration=silence_duration | |
| ) | |
| raw_text = text.strip() | |
| # Load reference | |
| ref_text_path = VOICE_SAMPLES[voice_choice]["text"] | |
| try: | |
| with open(ref_text_path, "r", encoding="utf-8") as f: | |
| ref_text_raw = f.read() | |
| except Exception as e: | |
| yield None, f"❌ Lỗi đọc file text mẫu: {e}" | |
| return | |
| yield None, "📄 Đang xử lý Reference..." | |
| # Load codes | |
| codes_path = VOICE_SAMPLES[voice_choice]["codes"] | |
| try: | |
| ref_codes_tensor = torch.load(codes_path, map_location="cpu") | |
| if isinstance(ref_codes_tensor, torch.Tensor): | |
| ref_codes = ref_codes_tensor.cpu().numpy() | |
| else: | |
| ref_codes = np.array(ref_codes_tensor) | |
| except Exception as e: | |
| yield None, f"❌ Lỗi khi tải codes: {e}" | |
| return | |
| if ref_codes is None or len(ref_codes) == 0: | |
| yield None, "❌ Codes tham chiếu không hợp lệ." | |
| return | |
| # Split text | |
| text_chunks = split_text_into_chunks(raw_text, max_chars=MAX_CHARS_PER_CHUNK) | |
| total_chunks = len(text_chunks) | |
| yield None, f"🚀 Bắt đầu tổng hợp ({total_chunks} đoạn)..." | |
| all_audio_segments = [] | |
| silence_pad = np.zeros(int(SAMPLE_RATE * settings.silence_duration), dtype=np.float32) | |
| start_time = time.time() | |
| try: | |
| for i, chunk in enumerate(text_chunks): | |
| yield None, f"⏳ Đang xử lý đoạn {i+1}/{total_chunks}... ({int((i/total_chunks)*100)}%)" | |
| # Phonemize | |
| ref_text_phoneme = phonemize_with_dict(ref_text_raw) | |
| input_text_phoneme = phonemize_with_dict(chunk) | |
| # Create prompt | |
| codes_str = "".join([f"<|speech_{idx}|>" for idx in ref_codes]) | |
| prompt = ( | |
| f"user: Convert the text to speech:<|TEXT_PROMPT_START|>{ref_text_phoneme} {input_text_phoneme}" | |
| f"<|TEXT_PROMPT_END|>\nassistant:<|SPEECH_GENERATION_START|>{codes_str}" | |
| ) | |
| # Generate | |
| output = backbone( | |
| prompt, | |
| max_tokens=2048, | |
| temperature=settings.temperature, | |
| top_k=settings.top_k, | |
| top_p=settings.top_p, | |
| stop=["<|SPEECH_GENERATION_END|>"], | |
| ) | |
| output_str = output["choices"][0]["text"] | |
| # Decode | |
| chunk_wav = decode_audio(output_str, codec) | |
| # Apply effects | |
| yield None, f"🎨 Áp dụng hiệu ứng cho đoạn {i+1}/{total_chunks}..." | |
| chunk_wav = apply_audio_effects(chunk_wav, settings) | |
| if chunk_wav is not None and len(chunk_wav) > 0: | |
| all_audio_segments.append(chunk_wav) | |
| if i < total_chunks - 1: | |
| all_audio_segments.append(silence_pad) | |
| if not all_audio_segments: | |
| yield None, "❌ Không sinh được audio nào." | |
| add_to_history(raw_text, voice_choice, None, 0, "Lỗi: Không sinh được audio", settings) | |
| update_stats(False) | |
| return | |
| yield None, "💾 Đang ghép file và lưu..." | |
| final_wav = np.concatenate(all_audio_segments) | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp: | |
| sf.write(tmp.name, final_wav, SAMPLE_RATE) | |
| output_path = tmp.name | |
| process_time = time.time() - start_time | |
| audio_duration = len(final_wav) / SAMPLE_RATE | |
| rtf = process_time / audio_duration | |
| # Lưu vào lịch sử | |
| permanent_path = add_to_history(raw_text, voice_choice, output_path, process_time, "Thành công", settings) | |
| update_stats(True) | |
| yield permanent_path, (f"✅ Hoàn tất! | Thời gian: {process_time:.2f}s | " | |
| f"RTF: {rtf:.3f} | Audio: {audio_duration:.2f}s") | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| add_to_history(raw_text, voice_choice, None, 0, f"Lỗi: {str(e)}", settings) | |
| update_stats(False) | |
| yield None, f"❌ Lỗi tổng hợp: {str(e)}" | |
| def load_history_item(item_index: str): | |
| """Tải một item từ lịch sử""" | |
| if not item_index or item_index.strip() == "": | |
| return None, "", "", "", "⚠️ Vui lòng nhập số thứ tự" | |
| try: | |
| index = int(item_index.strip()) - 1 | |
| history = load_history() | |
| if index < 0 or index >= len(history): | |
| return None, "", "", "", f"❌ Số thứ tự không hợp lệ (1-{len(history)})" | |
| record = history[index] | |
| audio_path = None | |
| if record.audio_path and os.path.exists(record.audio_path): | |
| audio_path = record.audio_path | |
| info = f""" | |
| 📅 Thời gian: {record.timestamp} | |
| ⏱️ Thời lượng: {record.duration:.2f}s | |
| 🎭 Giọng: {record.voice} | |
| 📊 Trạng thái: {record.status} | |
| 🆔 ID: {record.id} | |
| 🎛️ Cài đặt đã dùng: | |
| • Temperature: {record.settings.get('temperature', 1.0):.2f} | |
| • Top-K: {record.settings.get('top_k', 50)} | |
| • Top-P: {record.settings.get('top_p', 0.95):.2f} | |
| • Tốc độ: {record.settings.get('speed_ratio', 1.0):.1f}x | |
| • Cao độ: {record.settings.get('pitch_shift', 0):+d} semitones | |
| • Âm lượng: {record.settings.get('volume_gain', 1.0):.1f}x | |
| • Khoảng lặng: {record.settings.get('silence_duration', 0.15):.2f}s | |
| """.strip() | |
| return audio_path, record.full_text, record.voice, record.id, info | |
| except ValueError: | |
| return None, "", "", "", "❌ Vui lòng nhập số hợp lệ" | |
| except Exception as e: | |
| return None, "", "", "", f"❌ Lỗi: {str(e)}" | |
| def load_preset_to_ui(preset_name: str): | |
| """Tải preset vào UI""" | |
| presets = load_presets() | |
| if preset_name not in presets: | |
| return [1.0, 50, 0.95, 1.0, 0, 1.0, 0.15, f"❌ Preset '{preset_name}' không tồn tại"] | |
| settings = presets[preset_name] | |
| return [ | |
| settings.temperature, | |
| settings.top_k, | |
| settings.top_p, | |
| settings.speed_ratio, | |
| settings.pitch_shift, | |
| settings.volume_gain, | |
| settings.silence_duration, | |
| f"✅ Đã tải preset '{preset_name}'" | |
| ] | |
| def save_current_preset(name: str, temp: float, top_k: int, top_p: float, | |
| speed: float, pitch: int, volume: float, silence: float): | |
| """Lưu cài đặt hiện tại thành preset""" | |
| if not name or name.strip() == "": | |
| return "❌ Vui lòng nhập tên preset" | |
| name = name.strip() | |
| if name in DEFAULT_PRESETS: | |
| return f"❌ Không thể ghi đè preset mặc định '{name}'" | |
| settings = VoiceSettings( | |
| temperature=temp, | |
| top_k=top_k, | |
| top_p=top_p, | |
| speed_ratio=speed, | |
| pitch_shift=pitch, | |
| volume_gain=volume, | |
| silence_duration=silence | |
| ) | |
| success, msg = save_preset(name, settings) | |
| return msg | |
| def get_preset_list(): | |
| """Lấy danh sách preset""" | |
| presets = load_presets() | |
| return list(presets.keys()) | |
| # --- UI SETUP --- | |
| theme = gr.themes.Ocean( | |
| primary_hue="indigo", | |
| secondary_hue="cyan", | |
| neutral_hue="slate", | |
| font=[gr.themes.GoogleFont('Inter'), 'ui-sans-serif', 'system-ui'], | |
| ).set( | |
| button_primary_background_fill="linear-gradient(90deg, #6366f1 0%, #0ea5e9 100%)", | |
| button_primary_background_fill_hover="linear-gradient(90deg, #4f46e5 0%, #0284c7 100%)", | |
| ) | |
| css = """ | |
| .container { max-width: 1600px; margin: auto; } | |
| .header-box { | |
| text-align: center; | |
| margin-bottom: 25px; | |
| padding: 25px; | |
| background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); | |
| border-radius: 12px; | |
| color: white; | |
| box-shadow: 0 10px 40px rgba(0,0,0,0.2); | |
| } | |
| .header-title { | |
| font-size: 2.8rem; | |
| font-weight: 800; | |
| margin-bottom: 10px; | |
| } | |
| .gradient-text { | |
| background: linear-gradient(45deg, #60A5FA, #22D3EE, #A78BFA); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .status-box { | |
| font-weight: bold; | |
| text-align: center; | |
| border: none; | |
| background: transparent; | |
| } | |
| .settings-panel { | |
| background: #f8fafc; | |
| padding: 20px; | |
| border-radius: 10px; | |
| border: 2px solid #e2e8f0; | |
| } | |
| .history-container { | |
| max-height: 650px; | |
| overflow-y: auto; | |
| padding: 10px; | |
| background: #f1f5f9; | |
| border-radius: 8px; | |
| } | |
| .info-box { | |
| background: #f8fafc; | |
| padding: 15px; | |
| border-radius: 8px; | |
| border-left: 4px solid #3b82f6; | |
| margin: 10px 0; | |
| font-family: 'Courier New', monospace; | |
| font-size: 0.9em; | |
| } | |
| .preset-badge { | |
| display: inline-block; | |
| background: #3b82f6; | |
| color: white; | |
| padding: 4px 12px; | |
| border-radius: 12px; | |
| font-size: 0.85em; | |
| font-weight: 600; | |
| margin: 2px; | |
| } | |
| """ | |
| EXAMPLES_LIST = [ | |
| ["Về miền Tây không chỉ để ngắm nhìn sông nước hữu tình, mà còn để cảm nhận tấm chân tình của người dân nơi đây.", "Vĩnh (nam miền Nam)"], | |
| ["Hà Nội những ngày vào thu mang một vẻ đẹp trầm mặc và cổ kính đến lạ thường.", "Bình (nam miền Bắc)"], | |
| ["Thành phố Hồ Chí Minh luôn chuyển mình không ngừng với nhịp sống hối hả, năng động.", "Dung (nữ miền Nam)"], | |
| ] | |
| initial_status = (f"✅ Model đã tải thành công! (Chạy trên **{DEVICE_INFO}**). " | |
| f"Full features enabled: Custom Voice Settings ✅ | Presets ✅ | History ✅") if model_loaded else "❌ Lỗi khi tải model." | |
| with gr.Blocks(title=f"VieNeu-TTS v{VERSION}", theme=theme, css=css) as demo: | |
| with gr.Column(elem_classes="container"): | |
| gr.HTML(f""" | |
| <div class="header-box"> | |
| <h1 class="header-title"> | |
| <span style="font-size: 2.5rem;">🦜</span> | |
| <span class="gradient-text">VieNeu-TTS Studio Pro</span> | |
| </h1> | |
| <p style="margin: 10px 0; font-size: 1.1em;"> | |
| <strong>v{VERSION}</strong> | {DEVICE_INFO} | Advanced Voice Customization | |
| </p> | |
| <p style="font-size: 0.85rem; color: #94a3b8;"> | |
| 📁 History: {HISTORY_DIR} | 🎛️ Presets: {SETTINGS_DIR} | |
| </p> | |
| <div style="margin-top: 15px; display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;"> | |
| <span class="preset-badge">🎯 Custom Settings</span> | |
| <span class="preset-badge">💾 Presets Manager</span> | |
| <span class="preset-badge">🎨 Audio Effects</span> | |
| <span class="preset-badge">📊 Advanced Stats</span> | |
| <span class="preset-badge">🔍 Smart Search</span> | |
| </div> | |
| </div> | |
| """) | |
| status_banner = gr.Markdown(initial_status) | |
| # --- TABS --- | |
| with gr.Tabs(): | |
| # TAB 1: Tổng hợp với Custom Settings | |
| with gr.Tab("🎙️ Tổng hợp nâng cao"): | |
| with gr.Row(): | |
| with gr.Column(scale=2): | |
| text_input = gr.Textbox( | |
| label=f"📝 Văn bản (Chunk: {MAX_CHARS_PER_CHUNK} ký tự)", | |
| lines=6, | |
| placeholder="Nhập văn bản tiếng Việt cần tổng hợp...", | |
| value="Hà Nội, trái tim của Việt Nam, là một thành phố ngàn năm văn hiến với bề dày lịch sử và văn hóa độc đáo.", | |
| ) | |
| voice_select = gr.Dropdown( | |
| choices=list(VOICE_SAMPLES.keys()), | |
| value=list(VOICE_SAMPLES.keys())[0], | |
| label="👤 Chọn giọng mẫu", | |
| ) | |
| # Preset Manager | |
| with gr.Accordion("🎛️ Preset Manager", open=False): | |
| with gr.Row(): | |
| preset_dropdown = gr.Dropdown( | |
| choices=get_preset_list(), | |
| value="Mặc định", | |
| label="Chọn preset", | |
| scale=2 | |
| ) | |
| btn_load_preset = gr.Button("📂 Tải", size="sm", scale=1) | |
| btn_delete_preset = gr.Button("🗑️ Xóa", size="sm", variant="stop", scale=1) | |
| with gr.Row(): | |
| preset_name_input = gr.Textbox( | |
| label="Tên preset mới", | |
| placeholder="Nhập tên preset...", | |
| scale=3 | |
| ) | |
| btn_save_preset = gr.Button("💾 Lưu preset", variant="primary", scale=1) | |
| preset_status = gr.Textbox( | |
| label="Trạng thái preset", | |
| interactive=False, | |
| show_label=False | |
| ) | |
| # Custom Voice Settings | |
| with gr.Accordion("⚙️ Cài đặt giọng nói tùy chỉnh", open=True, elem_classes="settings-panel"): | |
| gr.Markdown("### 🎯 Model Parameters") | |
| with gr.Row(): | |
| temperature_slider = gr.Slider( | |
| minimum=0.1, maximum=2.0, value=1.0, step=0.1, | |
| label="🌡️ Temperature (Độ sáng tạo)", | |
| info="Cao = đa dạng, Thấp = ổn định" | |
| ) | |
| top_k_slider = gr.Slider( | |
| minimum=1, maximum=100, value=50, step=1, | |
| label="🔝 Top-K", | |
| info="Số lượng token được xem xét" | |
| ) | |
| top_p_slider = gr.Slider( | |
| minimum=0.1, maximum=1.0, value=0.95, step=0.05, | |
| label="🎲 Top-P (Nucleus Sampling)", | |
| info="Xác suất tích lũy" | |
| ) | |
| gr.Markdown("### 🎨 Audio Effects") | |
| with gr.Row(): | |
| speed_slider = gr.Slider( | |
| minimum=0.5, maximum=2.0, value=1.0, step=0.1, | |
| label="⚡ Tốc độ (Speed)", | |
| info="0.5x = chậm, 2.0x = nhanh" | |
| ) | |
| pitch_slider = gr.Slider( | |
| minimum=-12, maximum=12, value=0, step=1, | |
| label="🎵 Cao độ (Pitch Shift)", | |
| info="Semitones: -12 (thấp) đến +12 (cao)" | |
| ) | |
| with gr.Row(): | |
| volume_slider = gr.Slider( | |
| minimum=0.5, maximum=2.0, value=1.0, step=0.1, | |
| label="🔊 Âm lượng (Volume)", | |
| info="0.5x = nhỏ, 2.0x = to" | |
| ) | |
| silence_slider = gr.Slider( | |
| minimum=0.05, maximum=1.0, value=0.15, step=0.05, | |
| label="⏸️ Khoảng lặng (Pause)", | |
| info="Giây giữa các chunk" | |
| ) | |
| with gr.Row(): | |
| btn_reset_settings = gr.Button("🔄 Reset về mặc định", size="sm") | |
| with gr.Row(): | |
| btn_generate = gr.Button( | |
| "🎵 Bắt đầu tổng hợp", | |
| variant="primary", | |
| size="lg", | |
| interactive=model_loaded, | |
| scale=3 | |
| ) | |
| btn_clear = gr.Button("🗑️ Xóa", variant="secondary", size="lg", scale=1) | |
| with gr.Column(scale=1): | |
| audio_output = gr.Audio( | |
| label="🔊 Kết quả", | |
| type="filepath", | |
| autoplay=True | |
| ) | |
| status_output = gr.Textbox( | |
| label="📊 Trạng thái", | |
| elem_classes="status-box", | |
| value="Chờ nhập văn bản...", | |
| lines=3 | |
| ) | |
| gr.Markdown("### 💡 Quick Tips") | |
| gr.Markdown(""" | |
| - **Temperature ↑** → Giọng đa dạng hơn | |
| - **Speed < 1.0** → Rõ ràng, dễ nghe | |
| - **Pitch +3** → Giọng trẻ/nữ tính | |
| - **Pitch -3** → Giọng trầm/nam tính | |
| - **Volume 1.2** → Tăng âm lượng 20% | |
| """) | |
| gr.Examples( | |
| examples=EXAMPLES_LIST, | |
| inputs=[text_input, voice_select], | |
| label="💡 Các ví dụ nhanh" | |
| ) | |
| # TAB 2: Lịch sử nâng cao | |
| with gr.Tab("📜 Lịch sử & Phân tích"): | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| gr.Markdown("### 📋 Danh sách lịch sử") | |
| with gr.Row(): | |
| filter_voice = gr.Dropdown( | |
| choices=["Tất cả"] + list(VOICE_SAMPLES.keys()), | |
| value="Tất cả", | |
| label="Lọc theo giọng", | |
| scale=2 | |
| ) | |
| search_text = gr.Textbox( | |
| label="Tìm kiếm văn bản", | |
| placeholder="Nhập từ khóa...", | |
| scale=3 | |
| ) | |
| btn_search = gr.Button("🔍 Tìm", size="sm", variant="primary", scale=1) | |
| with gr.Row(): | |
| btn_refresh = gr.Button("🔄 Làm mới", size="sm", variant="secondary") | |
| btn_clear_all = gr.Button("🗑️ Xóa toàn bộ", size="sm", variant="stop") | |
| stats_display = gr.Textbox( | |
| value=get_processing_stats(), | |
| label="", | |
| show_label=False, | |
| interactive=False, | |
| container=False | |
| ) | |
| history_display = gr.HTML( | |
| value=get_history_list(), | |
| elem_classes="history-container" | |
| ) | |
| with gr.Column(scale=2): | |
| gr.Markdown("### 🔍 Chi tiết bản ghi") | |
| with gr.Row(): | |
| history_select = gr.Textbox( | |
| label="Nhập số thứ tự", | |
| placeholder="Số...", | |
| scale=3 | |
| ) | |
| btn_load_history = gr.Button("📂 Tải", variant="primary", scale=1) | |
| history_id = gr.Textbox( | |
| label="🆔 ID bản ghi", | |
| interactive=False, | |
| visible=False | |
| ) | |
| history_info = gr.Textbox( | |
| label="ℹ️ Thông tin chi tiết", | |
| lines=10, | |
| elem_classes="info-box" | |
| ) | |
| history_audio = gr.Audio( | |
| label="🔊 Audio", | |
| type="filepath" | |
| ) | |
| history_voice = gr.Textbox( | |
| label="🎭 Giọng đã dùng", | |
| interactive=False | |
| ) | |
| history_text = gr.Textbox( | |
| label="📄 Văn bản đầy đủ", | |
| lines=5, | |
| interactive=False | |
| ) | |
| with gr.Row(): | |
| btn_reuse = gr.Button("♻️ Tái sử dụng", variant="secondary", scale=1) | |
| btn_export = gr.Button("📥 Export", variant="primary", scale=1) | |
| # TAB 3: Thông tin & Hướng dẫn | |
| with gr.Tab("ℹ️ Thông tin & Hướng dẫn"): | |
| with gr.Row(): | |
| with gr.Column(): | |
| gr.Markdown(f""" | |
| ## 🎯 Về VieNeu-TTS Studio Pro | |
| **VieNeu-TTS** là hệ thống tổng hợp giọng nói tiếng Việt sử dụng công nghệ AI tiên tiến | |
| với khả năng tùy chỉnh giọng nói toàn diện. | |
| ### ⚙️ Cấu hình hiện tại | |
| ``` | |
| Version: {VERSION} | |
| Model Backbone: Q4 GGUF (llama-cpp) | |
| Codec: ONNX Decoder | |
| Sample Rate: {SAMPLE_RATE} Hz | |
| Max Chunk Size: {MAX_CHARS_PER_CHUNK} ký tự | |
| History Dir: {HISTORY_DIR} | |
| Presets Dir: {SETTINGS_DIR} | |
| ``` | |
| ### 🎭 Giọng mẫu ({len(VOICE_SAMPLES)} giọng) | |
| | Khu vực | Nam | Nữ | | |
| |---------|-----|-----| | |
| | **Miền Bắc** | Tuyên, Bình | Ngọc, Ly | | |
| | **Miền Nam** | Vĩnh, Nguyên, Sơn | Đoan, Dung | | |
| ### 🎛️ Tính năng nâng cao | |
| #### Model Parameters | |
| - **Temperature (0.1-2.0)**: Kiểm soát độ sáng tạo | |
| - `0.5-0.8`: Ổn định, nhất quán | |
| - `1.0`: Cân bằng (mặc định) | |
| - `1.2-2.0`: Đa dạng, biểu cảm | |
| - **Top-K (1-100)**: Giới hạn lựa chọn token | |
| - `20-30`: Bảo thủ | |
| - `50`: Cân bằng (mặc định) | |
| - `80-100`: Sáng tạo | |
| - **Top-P (0.1-1.0)**: Nucleus sampling | |
| - `0.9`: An toàn | |
| - `0.95`: Cân bằng (mặc định) | |
| - `1.0`: Tự do hoàn toàn | |
| #### Audio Effects | |
| - **Speed (0.5-2.0x)**: Thay đổi tốc độ không ảnh hưởng cao độ | |
| - **Pitch (-12 đến +12 semitones)**: Thay đổi cao độ giọng nói | |
| - **Volume (0.5-2.0x)**: Điều chỉnh âm lượng | |
| - **Silence (0.05-1.0s)**: Khoảng dừng giữa các câu | |
| ### 📚 Preset System | |
| Hệ thống preset giúp lưu và tái sử dụng cài đặt yêu thích: | |
| **Preset mặc định:** | |
| - 🎯 **Mặc định**: Cài đặt chuẩn | |
| - ⚡ **Giọng nhanh**: Speed 1.3x, pause ngắn | |
| - 🐢 **Giọng chậm**: Speed 0.8x, pause dài | |
| - 🎵 **Giọng trầm**: Pitch -3 | |
| - 🎶 **Giọng cao**: Pitch +3 | |
| - 🔥 **Nhiệt tình**: Temp 1.2, volume cao | |
| - 😌 **Bình tĩnh**: Temp 0.8, speed chậm | |
| **Tạo preset mới:** | |
| 1. Điều chỉnh các thông số theo ý muốn | |
| 2. Nhập tên preset | |
| 3. Nhấn "Lưu preset" | |
| ### 📊 History & Analytics | |
| - ✅ Lưu tự động mọi lần tổng hợp | |
| - 🔍 Tìm kiếm và lọc theo giọng | |
| - 📈 Thống kê chi tiết (thời gian, RTF, settings) | |
| - ♻️ Tái sử dụng cài đặt từ lịch sử | |
| - 🗑️ Tự động xóa khi > 100 bản ghi | |
| ### 🚀 Workflow gợi ý | |
| 1. **Thử nghiệm nhanh**: | |
| - Chọn giọng → Nhập text → Tổng hợp | |
| - Dùng preset mặc định | |
| 2. **Tùy chỉnh chi tiết**: | |
| - Thử các preset có sẵn | |
| - Điều chỉnh từng thông số | |
| - Lưu thành preset mới | |
| 3. **Production**: | |
| - Dùng preset đã tối ưu | |
| - Kiểm tra lịch sử để đảm bảo chất lượng | |
| - Export audio khi hài lòng | |
| ### 🎓 Tips & Tricks | |
| **Giọng nói tự nhiên**: | |
| ``` | |
| Temperature: 0.9-1.1 | |
| Speed: 1.0 | |
| Pitch: 0 | |
| ``` | |
| **Podcast/Audiobook**: | |
| ``` | |
| Temperature: 0.8 | |
| Speed: 0.9 | |
| Silence: 0.2s | |
| Volume: 1.1 | |
| ``` | |
| **Quảng cáo/Promotional**: | |
| ``` | |
| Temperature: 1.2 | |
| Speed: 1.1 | |
| Volume: 1.3 | |
| Pitch: +2 | |
| ``` | |
| **Tin tức/News**: | |
| ``` | |
| Temperature: 0.85 | |
| Speed: 1.0 | |
| Silence: 0.15s | |
| ``` | |
| ### 🔧 Troubleshooting | |
| **Giọng không tự nhiên?** | |
| - Giảm Temperature xuống 0.8-0.9 | |
| - Kiểm tra Speed (nên = 1.0) | |
| **Âm thanh vỡ/méo?** | |
| - Giảm Volume về 1.0 | |
| - Kiểm tra Pitch (tránh quá ±6) | |
| **Xử lý chậm?** | |
| - Chia nhỏ văn bản | |
| - Đóng các tab khác | |
| ### 📊 Thống kê hệ thống | |
| {get_processing_stats()} | |
| ### 🔗 Liên kết | |
| - 🌐 [GitHub Repository](https://github.com/pnnbao97/VieNeu-TTS) | |
| - 🤗 [Model Hub](https://huggingface.co/pnnbao-ump) | |
| - 📖 [Documentation](https://github.com/pnnbao97/VieNeu-TTS/wiki) | |
| --- | |
| **Phiên bản**: {VERSION} | **Tác giả**: Phạm Nguyễn Ngọc Bảo | |
| **License**: MIT | **Last Updated**: December 2024 | |
| """) | |
| # ==================== EVENT HANDLERS ==================== | |
| # Tab 1: Tổng hợp | |
| btn_generate.click( | |
| fn=synthesize_speech, | |
| inputs=[ | |
| text_input, voice_select, | |
| temperature_slider, top_k_slider, top_p_slider, | |
| speed_slider, pitch_slider, volume_slider, silence_slider | |
| ], | |
| outputs=[audio_output, status_output] | |
| ) | |
| btn_clear.click( | |
| fn=lambda: ("", None, "Đã xóa"), | |
| outputs=[text_input, audio_output, status_output] | |
| ) | |
| # Reset settings | |
| def reset_settings(): | |
| return [1.0, 50, 0.95, 1.0, 0, 1.0, 0.15, "✅ Đã reset về mặc định"] | |
| btn_reset_settings.click( | |
| fn=reset_settings, | |
| outputs=[ | |
| temperature_slider, top_k_slider, top_p_slider, | |
| speed_slider, pitch_slider, volume_slider, silence_slider, | |
| preset_status | |
| ] | |
| ) | |
| # Preset management | |
| btn_load_preset.click( | |
| fn=load_preset_to_ui, | |
| inputs=preset_dropdown, | |
| outputs=[ | |
| temperature_slider, top_k_slider, top_p_slider, | |
| speed_slider, pitch_slider, volume_slider, silence_slider, | |
| preset_status | |
| ] | |
| ) | |
| btn_save_preset.click( | |
| fn=save_current_preset, | |
| inputs=[ | |
| preset_name_input, | |
| temperature_slider, top_k_slider, top_p_slider, | |
| speed_slider, pitch_slider, volume_slider, silence_slider | |
| ], | |
| outputs=preset_status | |
| ).then( | |
| fn=lambda: (get_preset_list(), ""), | |
| outputs=[preset_dropdown, preset_name_input] | |
| ) | |
| def delete_preset_handler(name): | |
| success, msg = delete_preset(name) | |
| return get_preset_list(), msg | |
| btn_delete_preset.click( | |
| fn=delete_preset_handler, | |
| inputs=preset_dropdown, | |
| outputs=[preset_dropdown, preset_status] | |
| ) | |
| # Tab 2: Lịch sử | |
| def search_history(voice_filter, text_search): | |
| return get_history_list(voice_filter, text_search) | |
| btn_search.click( | |
| fn=search_history, | |
| inputs=[filter_voice, search_text], | |
| outputs=history_display | |
| ) | |
| btn_refresh.click( | |
| fn=lambda: get_history_list(), | |
| outputs=history_display | |
| ).then( | |
| fn=get_processing_stats, | |
| outputs=stats_display | |
| ) | |
| btn_load_history.click( | |
| fn=load_history_item, | |
| inputs=history_select, | |
| outputs=[history_audio, history_text, history_voice, history_id, history_info] | |
| ) | |
| def clear_all(): | |
| msg = clear_all_history() | |
| return get_history_list(), msg | |
| btn_clear_all.click( | |
| fn=clear_all, | |
| outputs=[history_display, stats_display] | |
| ) | |
| # Tái sử dụng văn bản và settings | |
| def reuse_from_history(text, voice, record_id): | |
| if not record_id: | |
| return [text, voice, 1.0, 50, 0.95, 1.0, 0, 1.0, 0.15, | |
| "⚠️ Không có bản ghi để tải cài đặt"] | |
| history = load_history() | |
| record = next((r for r in history if r.id == record_id), None) | |
| if not record: | |
| return [text, voice, 1.0, 50, 0.95, 1.0, 0, 1.0, 0.15, | |
| "❌ Không tìm thấy bản ghi"] | |
| settings = record.settings | |
| return [ | |
| text, | |
| voice, | |
| settings.get('temperature', 1.0), | |
| settings.get('top_k', 50), | |
| settings.get('top_p', 0.95), | |
| settings.get('speed_ratio', 1.0), | |
| settings.get('pitch_shift', 0), | |
| settings.get('volume_gain', 1.0), | |
| settings.get('silence_duration', 0.15), | |
| f"✅ Đã tải văn bản và cài đặt từ bản ghi #{record_id[:8]}" | |
| ] | |
| btn_reuse.click( | |
| fn=reuse_from_history, | |
| inputs=[history_text, history_voice, history_id], | |
| outputs=[ | |
| text_input, voice_select, | |
| temperature_slider, top_k_slider, top_p_slider, | |
| speed_slider, pitch_slider, volume_slider, silence_slider, | |
| status_output | |
| ] | |
| ) | |
| # Export audio (download) | |
| def export_audio(record_id): | |
| if not record_id: | |
| return None, "⚠️ Không có audio để export" | |
| history = load_history() | |
| record = next((r for r in history if r.id == record_id), None) | |
| if not record or not record.audio_path: | |
| return None, "❌ Không tìm thấy file audio" | |
| if not os.path.exists(record.audio_path): | |
| return None, "❌ File audio đã bị xóa" | |
| return record.audio_path, f"✅ Đã export file audio #{record_id[:8]}" | |
| btn_export.click( | |
| fn=export_audio, | |
| inputs=history_id, | |
| outputs=[history_audio, history_info] | |
| ) | |
| # Auto-refresh stats periodically | |
| def auto_refresh_stats(): | |
| return get_processing_stats() | |
| # Refresh stats every time a tab is clicked | |
| demo.load( | |
| fn=auto_refresh_stats, | |
| outputs=stats_display | |
| ) | |
| # Background processor thread | |
| def background_processor(): | |
| """Xử lý queue tổng hợp trong background""" | |
| global is_processing | |
| while True: | |
| task = processing_queue.get() | |
| if task is None: | |
| break | |
| is_processing = True | |
| text, voice, settings = task | |
| try: | |
| print(f"[Background] Bắt đầu tổng hợp: {text[:50]}...") | |
| result = synthesize_speech_internal(text, voice, settings) | |
| if result: | |
| print(f"[Background] ✅ Hoàn thành: {result}") | |
| else: | |
| print(f"[Background] ❌ Thất bại") | |
| except Exception as e: | |
| print(f"[Background] ❌ Lỗi: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| is_processing = False | |
| processing_queue.task_done() | |
| # Khởi động background thread | |
| bg_thread = threading.Thread(target=background_processor, daemon=True) | |
| bg_thread.start() | |
| if __name__ == "__main__": | |
| print(f"\n{'='*70}") | |
| print(f"🚀 VieNeu-TTS Studio Pro v{VERSION} đã sẵn sàng!") | |
| print(f"{'='*70}") | |
| print(f"📂 History Directory: {HISTORY_DIR}") | |
| print(f"🎛️ Presets Directory: {SETTINGS_DIR}") | |
| print(f"🎭 Voice Samples: {len(VOICE_SAMPLES)} giọng") | |
| print(f"⚙️ Device Info: {DEVICE_INFO}") | |
| print(f"📊 Default Presets: {len(DEFAULT_PRESETS)}") | |
| print(f"{'='*70}") | |
| print(f"\n🎯 Features:") | |
| print(f" ✅ Advanced Voice Customization") | |
| print(f" ✅ Preset Manager (Save/Load/Delete)") | |
| print(f" ✅ Audio Effects (Speed/Pitch/Volume)") | |
| print(f" ✅ Smart History with Search & Filter") | |
| print(f" ✅ Settings Reuse from History") | |
| print(f" ✅ Background Processing") | |
| print(f" ✅ Thread-Safe Operations") | |
| print(f" ✅ Auto-cleanup (100 records limit)") | |
| print(f"\n🌐 Starting Gradio interface...") | |
| print(f"{'='*70}\n") | |
| # Compatible với cả Gradio cũ và mới | |
| try: | |
| demo.queue(max_size=20).launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| show_error=True, | |
| share=False | |
| ) | |
| except Exception as e: | |
| print(f"⚠️ Lỗi khởi động với queue, thử không queue: {e}") | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| show_error=True, | |
| share=False | |
| ) |