Spaces:
Running
on
Zero
Running
on
Zero
Update gradio_app.py
Browse files- gradio_app.py +138 -363
gradio_app.py
CHANGED
|
@@ -10,54 +10,47 @@ import pickle
|
|
| 10 |
import hashlib
|
| 11 |
import numpy as np
|
| 12 |
from pydub import AudioSegment
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
|
|
|
|
| 15 |
|
| 16 |
print("⏳ Đang khởi động VieNeu-TTS...")
|
| 17 |
|
| 18 |
# --- 1. SETUP MODEL ---
|
| 19 |
-
print("📦 Đang tải model...")
|
| 20 |
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 21 |
print(f"🖥️ Sử dụng thiết bị: {device.upper()}")
|
| 22 |
|
| 23 |
-
# Cache
|
| 24 |
CACHE_DIR = "./reference_cache"
|
| 25 |
os.makedirs(CACHE_DIR, exist_ok=True)
|
| 26 |
-
|
| 27 |
reference_cache = {}
|
| 28 |
reference_cache_lock = threading.Lock()
|
| 29 |
|
|
|
|
| 30 |
def get_cache_path(cache_key):
|
| 31 |
-
"""Tạo đường dẫn file cache từ key"""
|
| 32 |
-
# Hash key để tạo tên file an toàn
|
| 33 |
key_hash = hashlib.md5(cache_key.encode()).hexdigest()
|
| 34 |
return os.path.join(CACHE_DIR, f"{key_hash}.pkl")
|
| 35 |
|
| 36 |
def load_cache_from_disk(cache_key):
|
| 37 |
-
"""Load cache từ disk nếu có"""
|
| 38 |
cache_path = get_cache_path(cache_key)
|
| 39 |
if os.path.exists(cache_path):
|
| 40 |
try:
|
| 41 |
-
with open(cache_path, 'rb') as f:
|
| 42 |
-
|
| 43 |
-
except:
|
| 44 |
-
return None
|
| 45 |
return None
|
| 46 |
|
| 47 |
def save_cache_to_disk(cache_key, ref_codes):
|
| 48 |
-
"""Lưu cache xuống disk"""
|
| 49 |
cache_path = get_cache_path(cache_key)
|
| 50 |
try:
|
| 51 |
-
with open(cache_path, 'wb') as f:
|
| 52 |
-
|
| 53 |
-
# Lưu metadata để dễ debug
|
| 54 |
-
meta_path = cache_path.replace('.pkl', '.txt')
|
| 55 |
-
with open(meta_path, 'w', encoding='utf-8') as f:
|
| 56 |
-
f.write(f"Cache key: {cache_key}\n")
|
| 57 |
-
f.write(f"Created: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
| 58 |
-
except Exception as e:
|
| 59 |
-
print(f" ⚠️ Không thể lưu cache: {e}")
|
| 60 |
|
|
|
|
| 61 |
try:
|
| 62 |
tts = VieNeuTTS(
|
| 63 |
backbone_repo="pnnbao-ump/VieNeu-TTS",
|
|
@@ -66,33 +59,9 @@ try:
|
|
| 66 |
codec_device=device
|
| 67 |
)
|
| 68 |
print("✅ Model đã tải xong!")
|
| 69 |
-
|
| 70 |
-
# Kiểm tra device thực tế
|
| 71 |
-
if hasattr(tts, 'backbone'):
|
| 72 |
-
if hasattr(tts.backbone, 'device'):
|
| 73 |
-
print(f" 📍 Backbone device: {tts.backbone.device}")
|
| 74 |
-
else:
|
| 75 |
-
# For transformers model
|
| 76 |
-
print(f" 📍 Backbone device: {next(tts.backbone.parameters()).device}")
|
| 77 |
-
|
| 78 |
-
if hasattr(tts, 'codec'):
|
| 79 |
-
if hasattr(tts.codec, 'device'):
|
| 80 |
-
print(f" 📍 Codec device: {tts.codec.device}")
|
| 81 |
-
else:
|
| 82 |
-
print(f" 📍 Codec device: {next(tts.codec.parameters()).device}")
|
| 83 |
-
|
| 84 |
-
print(f" 💾 GPU Memory allocated: {torch.cuda.memory_allocated(0) / 1024**3:.2f} GB" if torch.cuda.is_available() else "")
|
| 85 |
-
|
| 86 |
except Exception as e:
|
| 87 |
-
print(f"⚠️
|
| 88 |
-
|
| 89 |
-
def encode_reference(self, path): return None
|
| 90 |
-
def infer(self, text, ref, ref_text):
|
| 91 |
-
import numpy as np
|
| 92 |
-
# Giả lập độ trễ để test tính năng đo thời gian
|
| 93 |
-
time.sleep(1.5)
|
| 94 |
-
return np.random.uniform(-0.5, 0.5, 24000*3)
|
| 95 |
-
tts = MockTTS()
|
| 96 |
|
| 97 |
# --- 2. DATA ---
|
| 98 |
VOICE_SAMPLES = {
|
|
@@ -106,342 +75,148 @@ VOICE_SAMPLES = {
|
|
| 106 |
"Ly (nữ miền Bắc)": {"audio": "./sample/Ly (nữ miền Bắc).wav", "text": "./sample/Ly (nữ miền Bắc).txt"},
|
| 107 |
"Dung (nữ miền Nam)": {"audio": "./sample/Dung (nữ miền Nam).wav", "text": "./sample/Dung (nữ miền Nam).txt"},
|
| 108 |
"Nhỏ Ngọt Ngào": {"audio": "./sample/Nhỏ Ngọt Ngào.wav", "text": "./sample/Nhỏ Ngọt Ngào.txt"},
|
| 109 |
-
|
| 110 |
-
# Thêm giọng mới ở đây:
|
| 111 |
-
# "Tên Giọng": {"audio": "./sample/Tên_Giọng.wav", "text": "./sample/Tên_Giọng.txt"},
|
| 112 |
}
|
| 113 |
|
| 114 |
-
# --- 3.
|
| 115 |
-
def
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
if os.path.exists(text_path):
|
| 121 |
-
with open(text_path, "r", encoding="utf-8") as f:
|
| 122 |
-
ref_text = f.read()
|
| 123 |
-
return audio_path, ref_text
|
| 124 |
-
else:
|
| 125 |
-
return audio_path, "⚠️ Không tìm thấy file text mẫu."
|
| 126 |
-
except Exception as e:
|
| 127 |
-
return None, f"❌ Lỗi: {str(e)}"
|
| 128 |
-
return None, ""
|
| 129 |
-
|
| 130 |
-
def synthesize_speech(text, voice_choice, custom_audio, custom_text, mode_tab, speed_factor):
|
| 131 |
-
try:
|
| 132 |
-
if not text or text.strip() == "":
|
| 133 |
-
return None, "⚠️ Vui lòng nhập văn bản cần tổng hợp!"
|
| 134 |
-
|
| 135 |
-
# --- LOGIC CHECK LIMIT 250 ---
|
| 136 |
-
if len(text) > 250:
|
| 137 |
-
return None, f"❌ Văn bản quá dài ({len(text)}/250 ký tự)! Vui lòng cắt ngắn lại để đảm bảo chất lượng."
|
| 138 |
-
|
| 139 |
-
# Logic chọn Reference
|
| 140 |
-
if mode_tab == "custom_mode":
|
| 141 |
-
if custom_audio is None or not custom_text:
|
| 142 |
-
return None, "⚠️ Vui lòng tải lên Audio và nhập nội dung Audio đó."
|
| 143 |
-
ref_audio_path = custom_audio
|
| 144 |
-
ref_text_raw = custom_text
|
| 145 |
-
print("🎨 Mode: Custom Voice")
|
| 146 |
-
else: # Preset
|
| 147 |
-
if voice_choice not in VOICE_SAMPLES:
|
| 148 |
-
return None, "⚠️ Vui lòng chọn một giọng mẫu."
|
| 149 |
-
ref_audio_path = VOICE_SAMPLES[voice_choice]["audio"]
|
| 150 |
-
ref_text_path = VOICE_SAMPLES[voice_choice]["text"]
|
| 151 |
-
|
| 152 |
-
if not os.path.exists(ref_audio_path):
|
| 153 |
-
return None, f"❌ Không tìm thấy file audio: {ref_audio_path}"
|
| 154 |
-
|
| 155 |
-
with open(ref_text_path, "r", encoding="utf-8") as f:
|
| 156 |
-
ref_text_raw = f.read()
|
| 157 |
-
print(f"🎤 Mode: Preset Voice ({voice_choice})")
|
| 158 |
-
|
| 159 |
-
# Inference & Đo thời gian
|
| 160 |
-
print(f"📝 Text: {text[:50]}...")
|
| 161 |
-
|
| 162 |
-
start_time = time.time()
|
| 163 |
-
|
| 164 |
-
# Encode reference (with thread-safe cache + disk cache)
|
| 165 |
-
t1 = time.time()
|
| 166 |
-
cache_key = f"{mode_tab}:{voice_choice}" if mode_tab == "preset_mode" else ref_audio_path
|
| 167 |
-
|
| 168 |
-
with reference_cache_lock:
|
| 169 |
-
# Check memory cache first
|
| 170 |
-
if cache_key in reference_cache:
|
| 171 |
-
print(f" ✨ Using memory cache for {cache_key}")
|
| 172 |
-
ref_codes = reference_cache[cache_key]
|
| 173 |
-
else:
|
| 174 |
-
# Check disk cache
|
| 175 |
-
ref_codes = load_cache_from_disk(cache_key)
|
| 176 |
-
if ref_codes is not None:
|
| 177 |
-
print(f" 💿 Loaded from disk cache for {cache_key}")
|
| 178 |
-
reference_cache[cache_key] = ref_codes
|
| 179 |
-
else:
|
| 180 |
-
# Encode mới
|
| 181 |
-
print(f" 🔄 Encoding reference (first time for {cache_key})...")
|
| 182 |
-
ref_codes = tts.encode_reference(ref_audio_path)
|
| 183 |
-
reference_cache[cache_key] = ref_codes
|
| 184 |
-
save_cache_to_disk(cache_key, ref_codes)
|
| 185 |
-
print(f" 💾 Saved to disk cache for {cache_key}")
|
| 186 |
-
|
| 187 |
-
t2 = time.time()
|
| 188 |
-
print(f" ⏱️ Encode reference: {t2-t1:.2f}s")
|
| 189 |
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
t6 = time.time()
|
| 219 |
-
print(f" ⏱️ Speed adjustment: {t6-t5:.2f}s")
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
| 223 |
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
|
| 229 |
-
|
| 230 |
-
speed_info = f" (Speed: {speed_factor}x)" if speed_factor != 1.0 else ""
|
| 231 |
-
return output_path, f"✅ Thành công! (Mất {process_time:.2f} giây để tạo){speed_info}"
|
| 232 |
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
| 237 |
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
primary_hue="indigo",
|
| 242 |
-
secondary_hue="cyan",
|
| 243 |
-
neutral_hue="slate",
|
| 244 |
-
font=[gr.themes.GoogleFont('Inter'), 'ui-sans-serif', 'system-ui'],
|
| 245 |
-
).set(
|
| 246 |
-
button_primary_background_fill="linear-gradient(90deg, #6366f1 0%, #0ea5e9 100%)",
|
| 247 |
-
button_primary_background_fill_hover="linear-gradient(90deg, #4f46e5 0%, #0284c7 100%)",
|
| 248 |
-
block_shadow="0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
| 249 |
-
)
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
}
|
| 272 |
-
.header-desc {
|
| 273 |
-
font-size: 1.1rem;
|
| 274 |
-
color: #cbd5e1; /* Màu xám sáng (Slate-300) */
|
| 275 |
-
margin-bottom: 15px;
|
| 276 |
-
}
|
| 277 |
-
.link-group a {
|
| 278 |
-
text-decoration: none;
|
| 279 |
-
margin: 0 10px;
|
| 280 |
-
font-weight: 600;
|
| 281 |
-
color: #94a3b8; /* Màu link sáng hơn chút */
|
| 282 |
-
transition: color 0.2s;
|
| 283 |
-
}
|
| 284 |
-
.link-group a:hover { color: #38bdf8; text-shadow: 0 0 5px rgba(56, 189, 248, 0.5); }
|
| 285 |
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
border-left: 4px solid #f97316;
|
| 290 |
-
padding: 12px;
|
| 291 |
-
color: #9a3412;
|
| 292 |
-
font-size: 0.9rem;
|
| 293 |
-
border-radius: 4px;
|
| 294 |
-
margin-top: 10px;
|
| 295 |
-
margin-bottom: 10px;
|
| 296 |
-
}
|
| 297 |
-
"""
|
| 298 |
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
["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. Cùng ngồi xuồng ba lá len lỏi qua rặng dừa nước, nghe câu vọng cổ ngọt ngào thì còn gì bằng.", "Vĩnh (nam miền Nam)"],
|
| 302 |
-
|
| 303 |
-
# Nam Miền Bắc
|
| 304 |
-
["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. Đi dạo quanh Hồ Gươm vào sáng sớm, hít hà mùi hoa sữa nồng nàn và thưởng thức chút cốm làng Vòng là trải nghiệm khó quên.", "Bình (nam miền Bắc)"],
|
| 305 |
-
|
| 306 |
-
# Nam Miền Bắc
|
| 307 |
-
["Sự bùng nổ của trí tuệ nhân tạo đang định hình lại cách chúng ta làm việc và sinh sống. Từ xe tự lái đến trợ lý ảo thông minh, công nghệ đang dần xóa nhòa ranh giới giữa thực tại và những bộ phim viễn tưởng.", "Tuyên (nam miền Bắc)"],
|
| 308 |
-
|
| 309 |
-
# Nam Miền Nam
|
| 310 |
-
["Sài Gòn hối hả là thế, nhưng chỉ cần tấp vào một quán cà phê ven đường, gọi ly bạc xỉu đá và ngắm nhìn dòng người qua lại, bạn sẽ thấy thành phố này cũng có những khoảng lặng thật bình yên và đáng yêu.", "Nguyên (nam miền Nam)"],
|
| 311 |
-
|
| 312 |
-
# Nam Miền Nam
|
| 313 |
-
["Để đảm bảo tiến độ dự án quan trọng này, chúng ta cần tập trung tối đa nguồn lực và phối hợp chặt chẽ giữa các phòng ban. Mọi khó khăn phát sinh cần được báo cáo ngay lập tức để ban lãnh đạo xử lý kịp thời.", "Sơn (nam miền Nam)"],
|
| 314 |
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
|
| 325 |
-
with gr.Blocks(theme=theme, css=css, title="VieNeu-TTS
|
| 326 |
-
|
| 327 |
-
with gr.Column(elem_classes="container"):
|
| 328 |
-
# Header - Cập nhật class cho HTML
|
| 329 |
-
gr.HTML("""
|
| 330 |
-
<div class="header-box">
|
| 331 |
-
<div class="header-title">🎙️ VieNeu-TTS Studio</div>
|
| 332 |
-
<div class="header-desc">
|
| 333 |
-
Phiên bản: VieNeu-TTS-1000h (model mới nhất, train trên 1000 giờ dữ liệu)
|
| 334 |
-
</div>
|
| 335 |
-
<div class="link-group">
|
| 336 |
-
<a href="https://huggingface.co/pnnbao-ump/VieNeu-TTS" target="_blank">🤗 Model Card</a> •
|
| 337 |
-
<a href="https://huggingface.co/datasets/pnnbao-ump/VieNeu-TTS-1000h" target="_blank">📖 Dataset 1000h</a> •
|
| 338 |
-
<a href="https://github.com/pnnbao97/VieNeu-TTS" target="_blank">🦜 GitHub</a>
|
| 339 |
-
</div>
|
| 340 |
-
</div>
|
| 341 |
-
""")
|
| 342 |
|
| 343 |
-
with gr.Row(
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
with gr.Column(scale=3, variant="panel"):
|
| 347 |
-
gr.Markdown("### 📝 Văn bản đầu vào")
|
| 348 |
-
text_input = gr.Textbox(
|
| 349 |
-
label="Nhập văn bản",
|
| 350 |
-
placeholder="Nhập nội dung tiếng Việt cần chuyển thành giọng nói...",
|
| 351 |
-
lines=4,
|
| 352 |
-
value="Sự bùng nổ của trí tuệ nhân tạo đang định hình lại cách chúng ta làm việc và sinh sống. Từ xe tự lái đến trợ lý ảo thông minh, công nghệ đang dần xóa nhòa ranh giới giữa thực tại và những bộ phim viễn tưởng.",
|
| 353 |
-
show_label=False
|
| 354 |
-
)
|
| 355 |
-
|
| 356 |
-
# Counter + Warning
|
| 357 |
-
with gr.Row():
|
| 358 |
-
char_count = gr.HTML("<div style='text-align: right; color: #64748B; font-size: 0.8rem;'>0 / 250 ký tự</div>")
|
| 359 |
|
| 360 |
-
gr.Markdown("### 🗣️ Chọn giọng đọc")
|
| 361 |
with gr.Tabs() as tabs:
|
| 362 |
-
with gr.TabItem("
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
ref_audio_preview = gr.Audio(label="Audio mẫu", interactive=False, type="filepath")
|
| 371 |
-
ref_text_preview = gr.Markdown("...")
|
| 372 |
-
|
| 373 |
-
with gr.TabItem("🎙️ Giọng tùy chỉnh (Custom)", id="custom_mode"):
|
| 374 |
-
gr.Markdown("Tải lên giọng của bạn (Zero-shot Cloning)")
|
| 375 |
-
custom_audio = gr.Audio(label="File ghi âm (.wav)", type="filepath")
|
| 376 |
-
custom_text = gr.Textbox(label="Nội dung ghi âm", placeholder="Nhập chính xác lời thoại...")
|
| 377 |
-
|
| 378 |
-
current_mode = gr.Textbox(visible=False, value="preset_mode")
|
| 379 |
-
|
| 380 |
-
gr.Markdown("### ⚙️ Cài đặt nâng cao")
|
| 381 |
-
speed_slider = gr.Slider(
|
| 382 |
-
minimum=0.5,
|
| 383 |
-
maximum=2.0,
|
| 384 |
-
value=1.0,
|
| 385 |
-
step=0.1,
|
| 386 |
-
label="Tốc độ giọng nói (Speed)",
|
| 387 |
-
info="0.5x = chậm, 1.0x = bình thường, 2.0x = nhanh"
|
| 388 |
-
)
|
| 389 |
|
| 390 |
-
btn_generate = gr.Button("Tổng hợp giọng nói", variant="primary", size="lg")
|
| 391 |
-
|
| 392 |
-
# --- RIGHT: OUTPUT ---
|
| 393 |
-
with gr.Column(scale=2):
|
| 394 |
-
gr.Markdown("### 🎧 Kết quả")
|
| 395 |
-
with gr.Group():
|
| 396 |
-
audio_output = gr.Audio(label="Audio đầu ra", type="filepath", show_download_button=True, autoplay=True)
|
| 397 |
-
status_output = gr.Textbox(label="Trạng thái", show_label=False, elem_classes="status-box", placeholder="Sẵn sàng...")
|
| 398 |
-
|
| 399 |
-
# --- EXAMPLES ---
|
| 400 |
-
with gr.Row(elem_classes="container"):
|
| 401 |
with gr.Column():
|
| 402 |
-
gr.
|
| 403 |
-
gr.
|
| 404 |
-
|
| 405 |
-
# --- LOGIC ---
|
| 406 |
-
def update_count(text):
|
| 407 |
-
l = len(text)
|
| 408 |
-
if l > 250:
|
| 409 |
-
color = "#dc2626" # Red
|
| 410 |
-
msg = f"⚠️ <b>{l} / 250</b> - Quá giới hạn!"
|
| 411 |
-
elif l > 200:
|
| 412 |
-
color = "#ea580c" # Orange
|
| 413 |
-
msg = f"{l} / 250"
|
| 414 |
-
else:
|
| 415 |
-
color = "#64748B" # Gray
|
| 416 |
-
msg = f"{l} / 250 ký tự"
|
| 417 |
-
return f"<div style='text-align: right; color: {color}; font-size: 0.8rem; font-weight: bold'>{msg}</div>"
|
| 418 |
|
| 419 |
-
|
|
|
|
|
|
|
|
|
|
| 420 |
|
| 421 |
-
|
| 422 |
-
audio, text = load_reference_info(voice)
|
| 423 |
-
return audio, f"> *\"{text}\"*"
|
| 424 |
-
|
| 425 |
-
voice_select.change(update_ref_preview, voice_select, [ref_audio_preview, ref_text_preview])
|
| 426 |
-
demo.load(update_ref_preview, voice_select, [ref_audio_preview, ref_text_preview])
|
| 427 |
-
|
| 428 |
-
# Tab handling - FIXED WITH *ARGS
|
| 429 |
-
tab_preset = tabs.children[0]
|
| 430 |
-
tab_custom = tabs.children[1]
|
| 431 |
-
|
| 432 |
-
# Dùng *args để nhận bất kỳ số lượng tham số nào (0 hoặc 1), tránh lỗi Warning
|
| 433 |
-
tab_preset.select(fn=lambda *args: "preset_mode", inputs=None, outputs=current_mode)
|
| 434 |
-
tab_custom.select(fn=lambda *args: "custom_mode", inputs=None, outputs=current_mode)
|
| 435 |
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
outputs=[audio_output, status_output]
|
| 440 |
-
)
|
| 441 |
|
|
|
|
| 442 |
if __name__ == "__main__":
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
share=False
|
| 447 |
-
)
|
|
|
|
| 10 |
import hashlib
|
| 11 |
import numpy as np
|
| 12 |
from pydub import AudioSegment
|
| 13 |
+
from fastapi import FastAPI, HTTPException
|
| 14 |
+
from fastapi.responses import FileResponse
|
| 15 |
+
from pydantic import BaseModel
|
| 16 |
+
import base64
|
| 17 |
+
import io
|
| 18 |
|
| 19 |
+
# --- KHỞI TẠO FASTAPI ---
|
| 20 |
+
app = FastAPI()
|
| 21 |
|
| 22 |
print("⏳ Đang khởi động VieNeu-TTS...")
|
| 23 |
|
| 24 |
# --- 1. SETUP MODEL ---
|
|
|
|
| 25 |
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 26 |
print(f"🖥️ Sử dụng thiết bị: {device.upper()}")
|
| 27 |
|
| 28 |
+
# Cache
|
| 29 |
CACHE_DIR = "./reference_cache"
|
| 30 |
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
|
|
| 31 |
reference_cache = {}
|
| 32 |
reference_cache_lock = threading.Lock()
|
| 33 |
|
| 34 |
+
# Hàm Cache Helper
|
| 35 |
def get_cache_path(cache_key):
|
|
|
|
|
|
|
| 36 |
key_hash = hashlib.md5(cache_key.encode()).hexdigest()
|
| 37 |
return os.path.join(CACHE_DIR, f"{key_hash}.pkl")
|
| 38 |
|
| 39 |
def load_cache_from_disk(cache_key):
|
|
|
|
| 40 |
cache_path = get_cache_path(cache_key)
|
| 41 |
if os.path.exists(cache_path):
|
| 42 |
try:
|
| 43 |
+
with open(cache_path, 'rb') as f: return pickle.load(f)
|
| 44 |
+
except: return None
|
|
|
|
|
|
|
| 45 |
return None
|
| 46 |
|
| 47 |
def save_cache_to_disk(cache_key, ref_codes):
|
|
|
|
| 48 |
cache_path = get_cache_path(cache_key)
|
| 49 |
try:
|
| 50 |
+
with open(cache_path, 'wb') as f: pickle.dump(ref_codes, f)
|
| 51 |
+
except Exception: pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
+
# Load Model
|
| 54 |
try:
|
| 55 |
tts = VieNeuTTS(
|
| 56 |
backbone_repo="pnnbao-ump/VieNeu-TTS",
|
|
|
|
| 59 |
codec_device=device
|
| 60 |
)
|
| 61 |
print("✅ Model đã tải xong!")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
except Exception as e:
|
| 63 |
+
print(f"⚠️ Lỗi tải model: {e}")
|
| 64 |
+
tts = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
# --- 2. DATA ---
|
| 67 |
VOICE_SAMPLES = {
|
|
|
|
| 75 |
"Ly (nữ miền Bắc)": {"audio": "./sample/Ly (nữ miền Bắc).wav", "text": "./sample/Ly (nữ miền Bắc).txt"},
|
| 76 |
"Dung (nữ miền Nam)": {"audio": "./sample/Dung (nữ miền Nam).wav", "text": "./sample/Dung (nữ miền Nam).txt"},
|
| 77 |
"Nhỏ Ngọt Ngào": {"audio": "./sample/Nhỏ Ngọt Ngào.wav", "text": "./sample/Nhỏ Ngọt Ngào.txt"},
|
|
|
|
|
|
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
+
# --- 3. CORE LOGIC (Dùng chung cho cả API và UI) ---
|
| 81 |
+
def core_synthesize(text, voice_choice, speed_factor):
|
| 82 |
+
# Lấy thông tin giọng
|
| 83 |
+
voice_info = VOICE_SAMPLES.get(voice_choice)
|
| 84 |
+
if not voice_info:
|
| 85 |
+
raise ValueError("Giọng không tồn tại")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
+
ref_audio_path = voice_info["audio"]
|
| 88 |
+
ref_text_path = voice_info["text"]
|
| 89 |
+
|
| 90 |
+
# Load reference text
|
| 91 |
+
with open(ref_text_path, "r", encoding="utf-8") as f:
|
| 92 |
+
ref_text_raw = f.read()
|
| 93 |
+
|
| 94 |
+
# Encode reference (Cache logic)
|
| 95 |
+
cache_key = f"preset:{voice_choice}"
|
| 96 |
+
with reference_cache_lock:
|
| 97 |
+
if cache_key in reference_cache:
|
| 98 |
+
ref_codes = reference_cache[cache_key]
|
| 99 |
+
else:
|
| 100 |
+
ref_codes = load_cache_from_disk(cache_key)
|
| 101 |
+
if ref_codes is None:
|
| 102 |
+
ref_codes = tts.encode_reference(ref_audio_path)
|
| 103 |
+
save_cache_to_disk(cache_key, ref_codes)
|
| 104 |
+
reference_cache[cache_key] = ref_codes
|
| 105 |
+
|
| 106 |
+
# Infer
|
| 107 |
+
wav = tts.infer(text, ref_codes, ref_text_raw)
|
| 108 |
+
|
| 109 |
+
# Speed
|
| 110 |
+
if speed_factor != 1.0:
|
| 111 |
+
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
|
| 112 |
+
sf.write(tmp.name, wav, 24000)
|
| 113 |
+
tmp_path = tmp.name
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
+
sound = AudioSegment.from_wav(tmp_path)
|
| 116 |
+
new_frame_rate = int(sound.frame_rate * speed_factor)
|
| 117 |
+
sound_stretched = sound._spawn(sound.raw_data, overrides={'frame_rate': new_frame_rate})
|
| 118 |
+
sound_stretched = sound_stretched.set_frame_rate(24000)
|
| 119 |
|
| 120 |
+
wav = np.array(sound_stretched.get_array_of_samples()).astype(np.float32) / 32768.0
|
| 121 |
+
if sound_stretched.channels == 2:
|
| 122 |
+
wav = wav.reshape((-1, 2)).mean(axis=1)
|
| 123 |
+
os.unlink(tmp_path)
|
| 124 |
|
| 125 |
+
return wav
|
|
|
|
|
|
|
| 126 |
|
| 127 |
+
# --- 4. API ENDPOINTS (Cho Client App kết nối) ---
|
| 128 |
+
class FastTTSRequest(BaseModel):
|
| 129 |
+
text: str
|
| 130 |
+
voice_choice: str
|
| 131 |
+
speed_factor: float = 1.0
|
| 132 |
+
return_base64: bool = False
|
| 133 |
|
| 134 |
+
@app.get("/voices")
|
| 135 |
+
async def get_voices():
|
| 136 |
+
return {"voices": list(VOICE_SAMPLES.keys())}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
+
@app.post("/fast-tts")
|
| 139 |
+
async def fast_tts(request: FastTTSRequest):
|
| 140 |
+
try:
|
| 141 |
+
start = time.time()
|
| 142 |
+
wav = core_synthesize(request.text, request.voice_choice, request.speed_factor)
|
| 143 |
+
process_time = time.time() - start
|
| 144 |
+
|
| 145 |
+
# Convert to Base64
|
| 146 |
+
audio_buffer = io.BytesIO()
|
| 147 |
+
sf.write(audio_buffer, wav, 24000, format='WAV')
|
| 148 |
+
audio_bytes = audio_buffer.getvalue()
|
| 149 |
+
audio_base64 = base64.b64encode(audio_bytes).decode('utf-8')
|
| 150 |
+
|
| 151 |
+
return {
|
| 152 |
+
"status": "success",
|
| 153 |
+
"audio_base64": audio_base64,
|
| 154 |
+
"processing_time": process_time
|
| 155 |
+
}
|
| 156 |
+
except Exception as e:
|
| 157 |
+
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
+
# --- 5. GRADIO UI SETUP ---
|
| 160 |
+
# Dùng theme Soft để tránh lỗi
|
| 161 |
+
theme = gr.themes.Soft()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
+
# CSS
|
| 164 |
+
css = ".container { max-width: 900px; margin: auto; }"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
+
def ui_synthesize(text, voice, custom_audio, custom_text, mode, speed):
|
| 167 |
+
try:
|
| 168 |
+
start = time.time()
|
| 169 |
+
# Logic riêng cho UI (hỗ trợ custom voice)
|
| 170 |
+
if mode == "custom_mode":
|
| 171 |
+
ref_audio_path = custom_audio
|
| 172 |
+
ref_text_raw = custom_text
|
| 173 |
+
ref_codes = tts.encode_reference(ref_audio_path) # Không cache custom
|
| 174 |
+
wav = tts.infer(text, ref_codes, ref_text_raw)
|
| 175 |
+
# (Bỏ qua speed control cho custom để code gọn)
|
| 176 |
+
else:
|
| 177 |
+
wav = core_synthesize(text, voice, speed)
|
| 178 |
+
|
| 179 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
|
| 180 |
+
sf.write(tmp.name, wav, 24000)
|
| 181 |
+
path = tmp.name
|
| 182 |
+
return path, f"✅ Xong! ({time.time()-start:.2f}s)"
|
| 183 |
+
except Exception as e:
|
| 184 |
+
return None, f"❌ Lỗi: {e}"
|
| 185 |
|
| 186 |
+
with gr.Blocks(theme=theme, css=css, title="VieNeu-TTS") as demo:
|
| 187 |
+
gr.Markdown("# 🎙️ VieNeu-TTS (API + UI)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
+
with gr.Row():
|
| 190 |
+
with gr.Column():
|
| 191 |
+
inp_text = gr.Textbox(label="Văn bản", lines=3, value="Xin chào Việt Nam")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
|
|
|
| 193 |
with gr.Tabs() as tabs:
|
| 194 |
+
with gr.TabItem("Giọng mẫu", id="preset_mode"):
|
| 195 |
+
inp_voice = gr.Dropdown(list(VOICE_SAMPLES.keys()), value="Tuyên (nam miền Bắc)", label="Chọn giọng")
|
| 196 |
+
with gr.TabItem("Custom", id="custom_mode"):
|
| 197 |
+
inp_audio = gr.Audio(type="filepath")
|
| 198 |
+
inp_ref_text = gr.Textbox(label="Lời thoại mẫu")
|
| 199 |
+
|
| 200 |
+
inp_speed = gr.Slider(0.5, 2.0, value=1.0, label="Tốc độ")
|
| 201 |
+
btn = gr.Button("Đọc ngay", variant="primary")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
with gr.Column():
|
| 204 |
+
out_audio = gr.Audio(label="Kết quả", autoplay=True)
|
| 205 |
+
out_status = gr.Textbox(label="Trạng thái")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
|
| 207 |
+
# Ẩn hiện mode
|
| 208 |
+
mode_state = gr.Textbox(visible=False, value="preset_mode")
|
| 209 |
+
tabs.children[0].select(lambda: "preset_mode", None, mode_state)
|
| 210 |
+
tabs.children[1].select(lambda: "custom_mode", None, mode_state)
|
| 211 |
|
| 212 |
+
btn.click(ui_synthesize, [inp_text, inp_voice, inp_audio, inp_ref_text, mode_state, inp_speed], [out_audio, out_status])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
+
# --- 6. MOUNT GRADIO VÀO FASTAPI ---
|
| 215 |
+
# Đây là bước quan trọng nhất để chạy cả 2 cùng lúc
|
| 216 |
+
app = gr.mount_gradio_app(app, demo, path="/")
|
|
|
|
|
|
|
| 217 |
|
| 218 |
+
# --- 7. CHẠY SERVER ---
|
| 219 |
if __name__ == "__main__":
|
| 220 |
+
import uvicorn
|
| 221 |
+
# Chạy uvicorn thay vì demo.launch()
|
| 222 |
+
uvicorn.run(app, host="0.0.0.0", port=7860)
|
|
|
|
|
|