|
|
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...") |
|
|
|
|
|
|
|
|
MAX_CHARS_PER_CHUNK = 256 |
|
|
SAMPLE_RATE = 24000 |
|
|
DEVICE_INFO = "Q4 GGUF (llama-cpp) + ONNX Codec" |
|
|
VERSION = "3.0 Enhanced" |
|
|
|
|
|
|
|
|
@dataclass |
|
|
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 |
|
|
pitch_shift: int = 0 |
|
|
volume_gain: float = 1.0 |
|
|
silence_duration: float = 0.15 |
|
|
|
|
|
def to_dict(self): |
|
|
return asdict(self) |
|
|
|
|
|
@dataclass |
|
|
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) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
BACKBONE_REPO = "pnnbao-ump/VieNeu-TTS-q8-gguf" |
|
|
CODEC_REPO = "neuphonic/neucodec-onnx-decoder" |
|
|
|
|
|
|
|
|
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" |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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}" |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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_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ử" |
|
|
|
|
|
|
|
|
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']}") |
|
|
|
|
|
|
|
|
def apply_audio_effects(audio: np.ndarray, settings: VoiceSettings) -> np.ndarray: |
|
|
"""Áp dụng hiệu ứng audio""" |
|
|
try: |
|
|
|
|
|
if settings.speed_ratio != 1.0: |
|
|
audio = librosa.effects.time_stretch(audio, rate=settings.speed_ratio) |
|
|
|
|
|
|
|
|
if settings.pitch_shift != 0: |
|
|
audio = librosa.effects.pitch_shift( |
|
|
audio, |
|
|
sr=SAMPLE_RATE, |
|
|
n_steps=settings.pitch_shift |
|
|
) |
|
|
|
|
|
|
|
|
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, :] |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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)}") |
|
|
|
|
|
|
|
|
ref_text_phoneme = phonemize_with_dict(ref_text_raw) |
|
|
input_text_phoneme = phonemize_with_dict(chunk) |
|
|
|
|
|
|
|
|
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}" |
|
|
) |
|
|
|
|
|
|
|
|
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"] |
|
|
|
|
|
|
|
|
chunk_wav = decode_audio(output_str, codec) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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..." |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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)}%)" |
|
|
|
|
|
|
|
|
ref_text_phoneme = phonemize_with_dict(ref_text_raw) |
|
|
input_text_phoneme = phonemize_with_dict(chunk) |
|
|
|
|
|
|
|
|
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}" |
|
|
) |
|
|
|
|
|
|
|
|
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"] |
|
|
|
|
|
|
|
|
chunk_wav = decode_audio(output_str, codec) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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()) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
with gr.Tabs(): |
|
|
|
|
|
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", |
|
|
) |
|
|
|
|
|
|
|
|
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 |
|
|
) |
|
|
|
|
|
|
|
|
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" |
|
|
) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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 |
|
|
""") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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] |
|
|
) |
|
|
|
|
|
|
|
|
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 |
|
|
] |
|
|
) |
|
|
|
|
|
|
|
|
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] |
|
|
) |
|
|
|
|
|
|
|
|
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] |
|
|
) |
|
|
|
|
|
|
|
|
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 |
|
|
] |
|
|
) |
|
|
|
|
|
|
|
|
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] |
|
|
) |
|
|
|
|
|
|
|
|
def auto_refresh_stats(): |
|
|
return get_processing_stats() |
|
|
|
|
|
|
|
|
demo.load( |
|
|
fn=auto_refresh_stats, |
|
|
outputs=stats_display |
|
|
) |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
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 |
|
|
) |