Arrcttacsrks commited on
Commit
2c5686b
·
verified ·
1 Parent(s): 6b200af

Upload 2 files

Browse files
Files changed (2) hide show
  1. app-13.py +978 -0
  2. app-14.py +1024 -0
app-13.py ADDED
@@ -0,0 +1,978 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import soundfile as sf
3
+ import tempfile
4
+ import os
5
+ import time
6
+ import numpy as np
7
+ import librosa
8
+ import re
9
+ import json
10
+ import shutil
11
+ from pathlib import Path
12
+ from datetime import datetime
13
+ from llama_cpp import Llama
14
+ from neucodec import NeuCodecOnnxDecoder
15
+ import torch
16
+ from utils.phonemize_text import phonemize_with_dict
17
+ import threading
18
+ from queue import Queue
19
+
20
+ print("⏳ Đang khởi động VieNeu-TTS...")
21
+
22
+ # --- CONSTANTS ---
23
+ MAX_CHARS_PER_CHUNK = 256
24
+ SAMPLE_RATE = 24000
25
+ DEVICE_INFO = "Q4 GGUF (llama-cpp) + ONNX Codec"
26
+
27
+ # Thư mục lưu lịch sử - sử dụng thư mục tạm nếu không có quyền ghi
28
+ try:
29
+ HISTORY_DIR = "./tts_history"
30
+ os.makedirs(HISTORY_DIR, exist_ok=True)
31
+ # Test write permission
32
+ test_file = os.path.join(HISTORY_DIR, ".test")
33
+ with open(test_file, 'w') as f:
34
+ f.write("test")
35
+ os.remove(test_file)
36
+ except (PermissionError, OSError):
37
+ # Fallback to temp directory
38
+ HISTORY_DIR = os.path.join(tempfile.gettempdir(), "vieneu_tts_history")
39
+ os.makedirs(HISTORY_DIR, exist_ok=True)
40
+ print(f"⚠️ Không có quyền ghi, sử dụng thư mục tạm: {HISTORY_DIR}")
41
+
42
+ HISTORY_JSON = os.path.join(HISTORY_DIR, "history.json")
43
+
44
+ # Đường dẫn model
45
+ BACKBONE_REPO = "pnnbao-ump/VieNeu-TTS-q8-gguf"
46
+ CODEC_REPO = "neuphonic/neucodec-onnx-decoder"
47
+
48
+ # Giọng mẫu
49
+ VOICE_SAMPLES = {
50
+ "Tuyên (nam miền Bắc)": {
51
+ "audio": "./sample/Tuyên (nam miền Bắc).wav",
52
+ "text": "./sample/Tuyên (nam miền Bắc).txt",
53
+ "codes": "./sample/Tuyên (nam miền Bắc).pt"
54
+ },
55
+ "Vĩnh (nam miền Nam)": {
56
+ "audio": "./sample/Vĩnh (nam miền Nam).wav",
57
+ "text": "./sample/Vĩnh (nam miền Nam).txt",
58
+ "codes": "./sample/Vĩnh (nam miền Nam).pt"
59
+ },
60
+ "Bình (nam miền Bắc)": {
61
+ "audio": "./sample/Bình (nam miền Bắc).wav",
62
+ "text": "./sample/Bình (nam miền Bắc).txt",
63
+ "codes": "./sample/Bình (nam miền Bắc).pt"
64
+ },
65
+ "Nguyên (nam miền Nam)": {
66
+ "audio": "./sample/Nguyên (nam miền Nam).wav",
67
+ "text": "./sample/Nguyên (nam miền Nam).txt",
68
+ "codes": "./sample/Nguyên (nam miền Nam).pt"
69
+ },
70
+ "Sơn (nam miền Nam)": {
71
+ "audio": "./sample/Sơn (nam miền Nam).wav",
72
+ "text": "./sample/Sơn (nam miền Nam).txt",
73
+ "codes": "./sample/Sơn (nam miền Nam).pt"
74
+ },
75
+ "Đoan (nữ miền Nam)": {
76
+ "audio": "./sample/Đoan (nữ miền Nam).wav",
77
+ "text": "./sample/Đoan (nữ miền Nam).txt",
78
+ "codes": "./sample/Đoan (nữ miền Nam).pt"
79
+ },
80
+ "Ngọc (nữ miền Bắc)": {
81
+ "audio": "./sample/Ngọc (nữ miền Bắc).wav",
82
+ "text": "./sample/Ngọc (nữ miền Bắc).txt",
83
+ "codes": "./sample/Ngọc (nữ miền Bắc).pt"
84
+ },
85
+ "Ly (nữ miền Bắc)": {
86
+ "audio": "./sample/Ly (nữ miền Bắc).wav",
87
+ "text": "./sample/Ly (nữ miền Bắc).txt",
88
+ "codes": "./sample/Ly (nữ miền Bắc).pt"
89
+ },
90
+ "Dung (nữ miền Nam)": {
91
+ "audio": "./sample/Dung (nữ miền Nam).wav",
92
+ "text": "./sample/Dung (nữ miền Nam).txt",
93
+ "codes": "./sample/Dung (nữ miền Nam).pt"
94
+ }
95
+ }
96
+
97
+ # --- HISTORY MANAGEMENT ---
98
+ history_lock = threading.Lock()
99
+
100
+ def load_history():
101
+ """Tải lịch sử từ file JSON - Thread-safe"""
102
+ with history_lock:
103
+ if os.path.exists(HISTORY_JSON):
104
+ try:
105
+ with open(HISTORY_JSON, 'r', encoding='utf-8') as f:
106
+ return json.load(f)
107
+ except Exception as e:
108
+ print(f"⚠️ Lỗi đọc history.json: {e}")
109
+ return []
110
+ return []
111
+
112
+ def save_history(history):
113
+ """Lưu lịch sử vào file JSON - Thread-safe"""
114
+ with history_lock:
115
+ try:
116
+ with open(HISTORY_JSON, 'w', encoding='utf-8') as f:
117
+ json.dump(history, f, ensure_ascii=False, indent=2)
118
+ except Exception as e:
119
+ print(f"⚠️ Lỗi ghi history.json: {e}")
120
+
121
+ def add_to_history(text, voice, audio_path, duration, status):
122
+ """Thêm một bản ghi vào lịch sử - Thread-safe"""
123
+ try:
124
+ history = load_history()
125
+
126
+ # Tạo tên file duy nhất
127
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
128
+ filename = f"tts_{timestamp}.wav"
129
+ permanent_path = os.path.join(HISTORY_DIR, filename)
130
+
131
+ # Copy file audio vào thư mục lịch sử
132
+ if audio_path and os.path.exists(audio_path):
133
+ try:
134
+ shutil.copy2(audio_path, permanent_path)
135
+ except Exception as e:
136
+ print(f"⚠️ Không thể copy file audio: {e}")
137
+ permanent_path = audio_path # Dùng file tạm nếu không copy được
138
+ else:
139
+ permanent_path = None
140
+
141
+ record = {
142
+ "id": timestamp,
143
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
144
+ "text": text[:100] + "..." if len(text) > 100 else text,
145
+ "full_text": text,
146
+ "voice": voice,
147
+ "audio_path": permanent_path,
148
+ "duration": duration,
149
+ "status": status
150
+ }
151
+
152
+ history.insert(0, record) # Thêm vào đầu danh sách
153
+
154
+ # Giới hạn lịch sử tối đa 100 bản ghi
155
+ if len(history) > 100:
156
+ # Xóa file audio cũ
157
+ old_record = history.pop()
158
+ try:
159
+ if old_record.get('audio_path') and os.path.exists(old_record['audio_path']):
160
+ os.remove(old_record['audio_path'])
161
+ except Exception as e:
162
+ print(f"⚠️ Không thể xóa file cũ: {e}")
163
+
164
+ save_history(history)
165
+ return permanent_path
166
+ except Exception as e:
167
+ print(f"⚠️ Lỗi khi lưu lịch sử: {e}")
168
+ import traceback
169
+ traceback.print_exc()
170
+ return audio_path if audio_path else None
171
+
172
+ def get_history_list():
173
+ """Lấy danh sách lịch sử để hiển thị dạng HTML"""
174
+ history = load_history()
175
+ if not history:
176
+ return """
177
+ <div style='padding: 20px; text-align: center; color: #64748b;'>
178
+ <p style='font-size: 1.1em;'>📭 Chưa có lịch sử tổng hợp nào</p>
179
+ <p style='font-size: 0.9em;'>Các bản ghi sẽ xuất hiện ở đây sau khi bạn tổng hợp giọng nói</p>
180
+ </div>
181
+ """
182
+
183
+ html_parts = ["<div style='font-family: system-ui; line-height: 1.6;'>"]
184
+
185
+ for i, record in enumerate(history[:50], 1): # Hiển thị 50 bản ghi gần nhất
186
+ status_color = "#10b981" if record['status'] == "Thành công" else "#ef4444"
187
+ status_icon = "✅" if record['status'] == "Thành công" else "❌"
188
+
189
+ html_parts.append(f"""
190
+ <div style='
191
+ background: white;
192
+ border: 1px solid #e2e8f0;
193
+ border-radius: 8px;
194
+ padding: 15px;
195
+ margin-bottom: 12px;
196
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
197
+ '>
198
+ <div style='display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;'>
199
+ <div style='font-weight: 600; color: #1e293b; font-size: 0.95em;'>
200
+ <span style='color: #64748b;'>#{i}</span> {record['voice']}
201
+ </div>
202
+ <div style='font-size: 0.85em; color: #64748b;'>
203
+ {record['timestamp']}
204
+ </div>
205
+ </div>
206
+
207
+ <div style='
208
+ background: #f8fafc;
209
+ padding: 10px;
210
+ border-radius: 6px;
211
+ margin-bottom: 8px;
212
+ color: #334155;
213
+ font-size: 0.9em;
214
+ border-left: 3px solid #3b82f6;
215
+ '>
216
+ {record['text']}
217
+ </div>
218
+
219
+ <div style='display: flex; gap: 20px; font-size: 0.85em; color: #64748b;'>
220
+ <div>⏱️ {record['duration']:.2f}s</div>
221
+ <div style='color: {status_color}; font-weight: 500;'>
222
+ {status_icon} {record['status']}
223
+ </div>
224
+ <div style='margin-left: auto; color: #3b82f6; cursor: pointer;'>
225
+ ID: {record['id'][:13]}...
226
+ </div>
227
+ </div>
228
+ </div>
229
+ """)
230
+
231
+ html_parts.append("</div>")
232
+ return "".join(html_parts)
233
+
234
+ def delete_history_item(item_id):
235
+ """Xóa một item khỏi lịch sử"""
236
+ history = load_history()
237
+ new_history = []
238
+ deleted = False
239
+
240
+ for record in history:
241
+ if record['id'] == item_id:
242
+ # Xóa file audio
243
+ try:
244
+ if record.get('audio_path') and os.path.exists(record['audio_path']):
245
+ os.remove(record['audio_path'])
246
+ except Exception as e:
247
+ print(f"⚠️ Không thể xóa file: {e}")
248
+ deleted = True
249
+ else:
250
+ new_history.append(record)
251
+
252
+ if deleted:
253
+ save_history(new_history)
254
+ return True, "✅ Đã xóa bản ghi"
255
+ return False, "❌ Không tìm thấy bản ghi"
256
+
257
+ def clear_all_history():
258
+ """Xóa toàn bộ lịch sử"""
259
+ history = load_history()
260
+
261
+ # Xóa tất cả file audio
262
+ for record in history:
263
+ try:
264
+ if record.get('audio_path') and os.path.exists(record['audio_path']):
265
+ os.remove(record['audio_path'])
266
+ except Exception as e:
267
+ print(f"⚠️ Không thể xóa file: {e}")
268
+
269
+ # Xóa file JSON
270
+ save_history([])
271
+ return "✅ Đã xóa toàn bộ lịch sử"
272
+
273
+ # --- BACKGROUND PROCESSING QUEUE ---
274
+ processing_queue = Queue()
275
+ is_processing = False
276
+ processing_stats = {"total": 0, "success": 0, "failed": 0}
277
+
278
+ def background_processor():
279
+ """Xử lý queue tổng h���p trong background"""
280
+ global is_processing, processing_stats
281
+
282
+ while True:
283
+ task = processing_queue.get()
284
+ if task is None:
285
+ break
286
+
287
+ is_processing = True
288
+ text, voice = task
289
+
290
+ try:
291
+ print(f"[Background] Bắt đầu tổng hợp: {text[:50]}...")
292
+ result = synthesize_speech_internal(text, voice)
293
+
294
+ if result:
295
+ processing_stats["success"] += 1
296
+ print(f"[Background] ✅ Hoàn thành: {result}")
297
+ else:
298
+ processing_stats["failed"] += 1
299
+ print(f"[Background] ❌ Thất bại")
300
+
301
+ processing_stats["total"] += 1
302
+
303
+ except Exception as e:
304
+ processing_stats["failed"] += 1
305
+ processing_stats["total"] += 1
306
+ print(f"[Background] ❌ Lỗi: {e}")
307
+ import traceback
308
+ traceback.print_exc()
309
+
310
+ is_processing = False
311
+ processing_queue.task_done()
312
+
313
+ # Khởi động background thread
314
+ bg_thread = threading.Thread(target=background_processor, daemon=True)
315
+ bg_thread.start()
316
+
317
+ def get_processing_stats():
318
+ """Lấy thống kê xử lý"""
319
+ return f"📊 Tổng: {processing_stats['total']} | ✅ Thành công: {processing_stats['success']} | ❌ Thất bại: {processing_stats['failed']}"
320
+
321
+ def split_text_into_chunks(text, max_chars=256):
322
+ """Chia text thành chunks"""
323
+ sentences = re.split(r'([.!?,;])', text)
324
+ chunks = []
325
+ current = ""
326
+
327
+ for i in range(0, len(sentences), 2):
328
+ sentence = sentences[i]
329
+ punct = sentences[i+1] if i+1 < len(sentences) else ""
330
+ segment = sentence + punct
331
+
332
+ if len(current) + len(segment) <= max_chars:
333
+ current += segment
334
+ else:
335
+ if current:
336
+ chunks.append(current.strip())
337
+ current = segment
338
+
339
+ if current:
340
+ chunks.append(current.strip())
341
+
342
+ return chunks if chunks else [text]
343
+
344
+ def decode_audio(codes_str, codec):
345
+ """Decode speech tokens to audio"""
346
+ speech_ids = [int(num) for num in re.findall(r"<\|speech_(\d+)\|>", codes_str)]
347
+
348
+ if len(speech_ids) == 0:
349
+ print("Không tìm thấy mã giọng nói hợp lệ.")
350
+ return np.array([], dtype=np.float32)
351
+
352
+ codes = np.array(speech_ids, dtype=np.int32)[np.newaxis, np.newaxis, :]
353
+ recon = codec.decode_code(codes)
354
+ return recon[0, 0, :]
355
+
356
+ # --- MODEL LOADING ---
357
+ print("📦 Đang tải model Q4 GGUF và Codec ONNX...")
358
+ model_loaded = False
359
+ backbone = None
360
+ codec = None
361
+
362
+ try:
363
+ backbone = Llama.from_pretrained(
364
+ repo_id=BACKBONE_REPO,
365
+ filename="*.gguf",
366
+ verbose=False,
367
+ n_gpu_layers=-1,
368
+ n_ctx=2048,
369
+ mlock=True,
370
+ flash_attn=True,
371
+ )
372
+
373
+ codec = NeuCodecOnnxDecoder.from_pretrained(CODEC_REPO)
374
+
375
+ print("✅ Model đã tải thành công!")
376
+ model_loaded = True
377
+ except Exception as e:
378
+ import traceback
379
+ traceback.print_exc()
380
+ print(f"❌ Lỗi khi tải model: {e}")
381
+ model_loaded = False
382
+
383
+ # --- SYNTHESIS FUNCTION (Internal) ---
384
+ def synthesize_speech_internal(text, voice_choice):
385
+ """Internal synthesis function không phụ thuộc UI"""
386
+ global backbone, codec, model_loaded
387
+
388
+ if not model_loaded:
389
+ print("❌ Model chưa được tải")
390
+ return None
391
+
392
+ if not text or text.strip() == "":
393
+ print("❌ Text rỗng")
394
+ return None
395
+
396
+ if voice_choice not in VOICE_SAMPLES:
397
+ print(f"❌ Giọng không hợp lệ: {voice_choice}")
398
+ return None
399
+
400
+ raw_text = text.strip()
401
+
402
+ # Load reference text
403
+ ref_text_path = VOICE_SAMPLES[voice_choice]["text"]
404
+ try:
405
+ with open(ref_text_path, "r", encoding="utf-8") as f:
406
+ ref_text_raw = f.read()
407
+ except Exception as e:
408
+ print(f"❌ Lỗi đọc file text mẫu: {e}")
409
+ return None
410
+
411
+ # Tải codes
412
+ ref_codes = None
413
+ codes_path = VOICE_SAMPLES[voice_choice]["codes"]
414
+
415
+ try:
416
+ ref_codes_tensor = torch.load(codes_path, map_location="cpu")
417
+ if isinstance(ref_codes_tensor, torch.Tensor):
418
+ ref_codes = ref_codes_tensor.cpu().numpy()
419
+ else:
420
+ ref_codes = np.array(ref_codes_tensor)
421
+ except Exception as e:
422
+ print(f"❌ Lỗi khi tải codes: {e}")
423
+ return None
424
+
425
+ if ref_codes is None or len(ref_codes) == 0:
426
+ print("❌ Codes rỗng")
427
+ return None
428
+
429
+ # Split text
430
+ text_chunks = split_text_into_chunks(raw_text, max_chars=MAX_CHARS_PER_CHUNK)
431
+
432
+ all_audio_segments = []
433
+ silence_pad = np.zeros(int(SAMPLE_RATE * 0.15), dtype=np.float32)
434
+
435
+ start_time = time.time()
436
+
437
+ try:
438
+ for i, chunk in enumerate(text_chunks):
439
+ print(f"[Internal] Xử lý chunk {i+1}/{len(text_chunks)}")
440
+
441
+ # Phonemize
442
+ ref_text_phoneme = phonemize_with_dict(ref_text_raw)
443
+ input_text_phoneme = phonemize_with_dict(chunk)
444
+
445
+ # Create prompt
446
+ codes_str = "".join([f"<|speech_{idx}|>" for idx in ref_codes])
447
+
448
+ prompt = (
449
+ f"user: Convert the text to speech:<|TEXT_PROMPT_START|>{ref_text_phoneme} {input_text_phoneme}"
450
+ f"<|TEXT_PROMPT_END|>\nassistant:<|SPEECH_GENERATION_START|>{codes_str}"
451
+ )
452
+
453
+ # Generate
454
+ output = backbone(
455
+ prompt,
456
+ max_tokens=2048,
457
+ temperature=1.0,
458
+ top_k=50,
459
+ stop=["<|SPEECH_GENERATION_END|>"],
460
+ )
461
+ output_str = output["choices"][0]["text"]
462
+
463
+ # Decode
464
+ chunk_wav = decode_audio(output_str, codec)
465
+
466
+ if chunk_wav is not None and len(chunk_wav) > 0:
467
+ all_audio_segments.append(chunk_wav)
468
+ if i < len(text_chunks) - 1:
469
+ all_audio_segments.append(silence_pad)
470
+
471
+ if not all_audio_segments:
472
+ print("❌ Không có audio segment nào")
473
+ add_to_history(raw_text, voice_choice, None, 0, "Lỗi: Không sinh được audio")
474
+ return None
475
+
476
+ final_wav = np.concatenate(all_audio_segments)
477
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
478
+ sf.write(tmp.name, final_wav, SAMPLE_RATE)
479
+ output_path = tmp.name
480
+
481
+ process_time = time.time() - start_time
482
+
483
+ # Lưu vào lịch sử
484
+ permanent_path = add_to_history(raw_text, voice_choice, output_path, process_time, "Thành công")
485
+
486
+ print(f"✅ Hoàn thành: {permanent_path}")
487
+ return permanent_path
488
+
489
+ except Exception as e:
490
+ import traceback
491
+ traceback.print_exc()
492
+ add_to_history(raw_text, voice_choice, None, 0, f"Lỗi: {str(e)}")
493
+ return None
494
+
495
+ # --- SYNTHESIS FUNCTION (UI) ---
496
+ def synthesize_speech(text, voice_choice):
497
+ """Main synthesis function với UI feedback"""
498
+ global backbone, codec, model_loaded
499
+
500
+ if not model_loaded:
501
+ yield None, "⚠️ Model chưa tải. Vui lòng kiểm tra lỗi console!"
502
+ return
503
+
504
+ if not text or text.strip() == "":
505
+ yield None, "⚠️ Vui lòng nhập văn bản!"
506
+ return
507
+
508
+ if voice_choice not in VOICE_SAMPLES:
509
+ yield None, "⚠️ Vui lòng chọn giọng mẫu."
510
+ return
511
+
512
+ raw_text = text.strip()
513
+
514
+ # Load reference text
515
+ ref_text_path = VOICE_SAMPLES[voice_choice]["text"]
516
+ try:
517
+ with open(ref_text_path, "r", encoding="utf-8") as f:
518
+ ref_text_raw = f.read()
519
+ except Exception as e:
520
+ yield None, f"❌ Lỗi đọc file text mẫu: {e}"
521
+ return
522
+
523
+ yield None, "📄 Đang xử lý Reference..."
524
+
525
+ # Tải codes
526
+ ref_codes = None
527
+ codes_path = VOICE_SAMPLES[voice_choice]["codes"]
528
+
529
+ try:
530
+ ref_codes_tensor = torch.load(codes_path, map_location="cpu")
531
+ if isinstance(ref_codes_tensor, torch.Tensor):
532
+ ref_codes = ref_codes_tensor.cpu().numpy()
533
+ else:
534
+ ref_codes = np.array(ref_codes_tensor)
535
+ except Exception as e:
536
+ yield None, f"❌ Lỗi khi tải codes: {e}"
537
+ return
538
+
539
+ if ref_codes is None or len(ref_codes) == 0:
540
+ yield None, "❌ Codes tham chiếu không hợp lệ."
541
+ return
542
+
543
+ # Split text
544
+ text_chunks = split_text_into_chunks(raw_text, max_chars=MAX_CHARS_PER_CHUNK)
545
+ total_chunks = len(text_chunks)
546
+
547
+ yield None, f"🚀 Bắt đầu tổng hợp ({total_chunks} đoạn)..."
548
+
549
+ all_audio_segments = []
550
+ silence_pad = np.zeros(int(SAMPLE_RATE * 0.15), dtype=np.float32)
551
+
552
+ start_time = time.time()
553
+
554
+ try:
555
+ for i, chunk in enumerate(text_chunks):
556
+ yield None, f"⏳ Đang xử lý đoạn {i+1}/{total_chunks}... ({int((i/total_chunks)*100)}%)"
557
+
558
+ # Phonemize
559
+ ref_text_phoneme = phonemize_with_dict(ref_text_raw)
560
+ input_text_phoneme = phonemize_with_dict(chunk)
561
+
562
+ # Create prompt
563
+ codes_str = "".join([f"<|speech_{idx}|>" for idx in ref_codes])
564
+
565
+ prompt = (
566
+ f"user: Convert the text to speech:<|TEXT_PROMPT_START|>{ref_text_phoneme} {input_text_phoneme}"
567
+ f"<|TEXT_PROMPT_END|>\nassistant:<|SPEECH_GENERATION_START|>{codes_str}"
568
+ )
569
+
570
+ # Generate
571
+ output = backbone(
572
+ prompt,
573
+ max_tokens=2048,
574
+ temperature=1.0,
575
+ top_k=50,
576
+ stop=["<|SPEECH_GENERATION_END|>"],
577
+ )
578
+ output_str = output["choices"][0]["text"]
579
+
580
+ # Decode
581
+ chunk_wav = decode_audio(output_str, codec)
582
+
583
+ if chunk_wav is not None and len(chunk_wav) > 0:
584
+ all_audio_segments.append(chunk_wav)
585
+ if i < total_chunks - 1:
586
+ all_audio_segments.append(silence_pad)
587
+
588
+ if not all_audio_segments:
589
+ yield None, "❌ Không sinh được audio nào."
590
+ add_to_history(raw_text, voice_choice, None, 0, "Lỗi: Không sinh được audio")
591
+ return
592
+
593
+ yield None, "💾 Đang ghép file và lưu..."
594
+
595
+ final_wav = np.concatenate(all_audio_segments)
596
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
597
+ sf.write(tmp.name, final_wav, SAMPLE_RATE)
598
+ output_path = tmp.name
599
+
600
+ process_time = time.time() - start_time
601
+
602
+ # Lưu vào lịch sử
603
+ permanent_path = add_to_history(raw_text, voice_choice, output_path, process_time, "Thành công")
604
+
605
+ yield permanent_path, f"✅ Hoàn tất! (Tổng thời gian: {process_time:.2f}s | RTF: {process_time/(len(final_wav)/SAMPLE_RATE):.3f})"
606
+
607
+ except Exception as e:
608
+ import traceback
609
+ traceback.print_exc()
610
+ add_to_history(raw_text, voice_choice, None, 0, f"Lỗi: {str(e)}")
611
+ yield None, f"❌ Lỗi tổng hợp: {str(e)}"
612
+
613
+ def refresh_history():
614
+ """Làm mới danh sách lịch sử"""
615
+ return get_history_list()
616
+
617
+ def load_history_item(item_index):
618
+ """Tải một item từ lịch sử theo số thứ tự"""
619
+ if not item_index or item_index.strip() == "":
620
+ return None, "", "", "⚠️ Vui lòng nhập số thứ tự"
621
+
622
+ try:
623
+ index = int(item_index.strip()) - 1
624
+ history = load_history()
625
+
626
+ if index < 0 or index >= len(history):
627
+ return None, "", "", f"❌ Số thứ tự không hợp lệ (1-{len(history)})"
628
+
629
+ record = history[index]
630
+
631
+ audio_path = None
632
+ if record.get('audio_path') and os.path.exists(record['audio_path']):
633
+ audio_path = record['audio_path']
634
+
635
+ info = f"""
636
+ 📅 Thời gian: {record['timestamp']}
637
+ ⏱️ Thời lượng: {record['duration']:.2f}s
638
+ 🎭 Giọng: {record['voice']}
639
+ 📊 Trạng thái: {record['status']}
640
+ 🆔 ID: {record['id']}
641
+ """.strip()
642
+
643
+ return audio_path, record['full_text'], record['voice'], info
644
+
645
+ except ValueError:
646
+ return None, "", "", "❌ Vui lòng nhập số hợp lệ"
647
+ except Exception as e:
648
+ return None, "", "", f"❌ Lỗi: {str(e)}"
649
+
650
+ # --- UI SETUP ---
651
+ theme = gr.themes.Ocean(
652
+ primary_hue="indigo",
653
+ secondary_hue="cyan",
654
+ neutral_hue="slate",
655
+ font=[gr.themes.GoogleFont('Inter'), 'ui-sans-serif', 'system-ui'],
656
+ ).set(
657
+ button_primary_background_fill="linear-gradient(90deg, #6366f1 0%, #0ea5e9 100%)",
658
+ button_primary_background_fill_hover="linear-gradient(90deg, #4f46e5 0%, #0284c7 100%)",
659
+ )
660
+
661
+ css = """
662
+ .container { max-width: 1400px; margin: auto; }
663
+ .header-box {
664
+ text-align: center;
665
+ margin-bottom: 25px;
666
+ padding: 25px;
667
+ background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
668
+ border-radius: 12px;
669
+ color: white;
670
+ }
671
+ .header-title {
672
+ font-size: 2.5rem;
673
+ font-weight: 800;
674
+ }
675
+ .gradient-text {
676
+ background: -webkit-linear-gradient(45deg, #60A5FA, #22D3EE);
677
+ -webkit-background-clip: text;
678
+ -webkit-text-fill-color: transparent;
679
+ }
680
+ .status-box {
681
+ font-weight: bold;
682
+ text-align: center;
683
+ border: none;
684
+ background: transparent;
685
+ }
686
+ .model-card-content {
687
+ display: flex;
688
+ flex-wrap: wrap;
689
+ justify-content: center;
690
+ align-items: center;
691
+ gap: 15px;
692
+ font-size: 0.9rem;
693
+ color: #cbd5e1;
694
+ }
695
+ .model-card-item {
696
+ display: flex;
697
+ align-items: center;
698
+ justify-content: center;
699
+ gap: 6px;
700
+ color: #94a3b8;
701
+ }
702
+ .model-card-link {
703
+ color: #3b82f6;
704
+ text-decoration: none;
705
+ font-weight: 500;
706
+ transition: color 0.2s;
707
+ }
708
+ .model-card-link:hover {
709
+ color: #2563eb;
710
+ text-decoration: underline;
711
+ }
712
+ .history-container {
713
+ max-height: 650px;
714
+ overflow-y: auto;
715
+ padding: 10px;
716
+ background: #f1f5f9;
717
+ border-radius: 8px;
718
+ }
719
+ .info-box {
720
+ background: #f8fafc;
721
+ padding: 12px;
722
+ border-radius: 6px;
723
+ border-left: 4px solid #3b82f6;
724
+ margin: 10px 0;
725
+ }
726
+ """
727
+
728
+ EXAMPLES_LIST = [
729
+ ["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)"],
730
+ ["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)"],
731
+ ["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)"],
732
+ ]
733
+
734
+ initial_status = f"✅ Model đã tải thành công! (Chạy trên **{DEVICE_INFO}**). Hỗ trợ xử lý background và lưu lịch sử." if model_loaded else "❌ Lỗi khi tải model."
735
+
736
+ with gr.Blocks(title="VieNeu-TTS", theme=theme, css=css) as demo:
737
+ with gr.Column(elem_classes="container"):
738
+ gr.HTML(f"""
739
+ <div class="header-box">
740
+ <h1 class="header-title">
741
+ <span class="header-icon">🦜</span>
742
+ <span class="gradient-text">VieNeu-TTS Studio</span>
743
+ </h1>
744
+ <p style="margin: 10px 0;">Chế độ: {DEVICE_INFO} | Background Processing ✅ | History ✅</p>
745
+ <p style="font-size: 0.85rem; color: #94a3b8;">📁 Lịch sử lưu tại: {HISTORY_DIR}</p>
746
+ <div class="model-card-content">
747
+ <div class="model-card-item">
748
+ <strong>Repository:</strong>
749
+ <a href="https://github.com/pnnbao97/VieNeu-TTS" target="_blank" class="model-card-link">GitHub</a>
750
+ </div>
751
+ <div class="model-card-item">
752
+ <strong>Tác giả:</strong>
753
+ <span>Phạm Nguyễn Ngọc Bảo</span>
754
+ </div>
755
+ </div>
756
+ </div>
757
+ """)
758
+
759
+ status_banner = gr.Markdown(initial_status)
760
+
761
+ # --- TABS ---
762
+ with gr.Tabs():
763
+ # TAB 1: Tổng hợp
764
+ with gr.Tab("🎙️ Tổng hợp"):
765
+ with gr.Row(elem_classes="container"):
766
+ with gr.Column(scale=3):
767
+ text_input = gr.Textbox(
768
+ label=f"📝 Văn bản (Chia chunk: {MAX_CHARS_PER_CHUNK} ký tự)",
769
+ lines=7,
770
+ placeholder="Nhập văn bản tiếng Việt cần tổng hợp...",
771
+ 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. Bước chân trên những con phố cổ kính quanh Hồ Hoàn Kiếm, du khách như được du hành ngược thời gian.",
772
+ )
773
+
774
+ voice_select = gr.Dropdown(
775
+ choices=list(VOICE_SAMPLES.keys()),
776
+ value=list(VOICE_SAMPLES.keys())[0],
777
+ label="👤 Chọn giọng mẫu",
778
+ )
779
+
780
+ with gr.Row():
781
+ btn_generate = gr.Button("🎵 Bắt đầu tổng hợp", variant="primary", size="lg", interactive=model_loaded)
782
+ btn_clear = gr.Button("🗑️ Xóa", variant="secondary", size="lg")
783
+
784
+ with gr.Column(scale=2):
785
+ audio_output = gr.Audio(
786
+ label="🔊 Kết quả",
787
+ type="filepath",
788
+ autoplay=True
789
+ )
790
+ status_output = gr.Textbox(
791
+ label="📊 Trạng thái",
792
+ elem_classes="status-box",
793
+ value="Chờ nhập văn bản...",
794
+ lines=2
795
+ )
796
+
797
+ gr.Examples(
798
+ examples=EXAMPLES_LIST,
799
+ inputs=[text_input, voice_select],
800
+ outputs=[audio_output, status_output],
801
+ fn=synthesize_speech,
802
+ cache_examples=False,
803
+ label="💡 Các ví dụ nhanh"
804
+ )
805
+
806
+ # TAB 2: Lịch sử
807
+ with gr.Tab("📜 Lịch sử"):
808
+ with gr.Row():
809
+ with gr.Column(scale=3):
810
+ gr.Markdown("### 📋 Danh sách lịch sử")
811
+
812
+ with gr.Row():
813
+ btn_refresh = gr.Button("🔄 Làm mới", size="sm", variant="secondary")
814
+ btn_clear_all = gr.Button("🗑️ Xóa toàn bộ", size="sm", variant="stop")
815
+ stats_display = gr.Textbox(
816
+ value=get_processing_stats(),
817
+ label="",
818
+ show_label=False,
819
+ interactive=False,
820
+ container=False
821
+ )
822
+
823
+ history_display = gr.HTML(
824
+ value=get_history_list(),
825
+ elem_classes="history-container"
826
+ )
827
+
828
+ with gr.Column(scale=2):
829
+ gr.Markdown("### 🔍 Chi tiết bản ghi")
830
+
831
+ with gr.Row():
832
+ history_select = gr.Textbox(
833
+ label="Nhập số thứ tự (vd: 1, 2, 3...)",
834
+ placeholder="Nhập số...",
835
+ scale=3
836
+ )
837
+ btn_load_history = gr.Button("📂 Tải", variant="primary", scale=1)
838
+
839
+ history_info = gr.Textbox(
840
+ label="ℹ️ Thông tin",
841
+ lines=6,
842
+ elem_classes="info-box"
843
+ )
844
+
845
+ history_audio = gr.Audio(
846
+ label="🔊 Audio",
847
+ type="filepath"
848
+ )
849
+
850
+ history_voice = gr.Textbox(
851
+ label="🎭 Giọng đã dùng",
852
+ interactive=False
853
+ )
854
+
855
+ history_text = gr.Textbox(
856
+ label="📄 Văn bản đầy đủ",
857
+ lines=5,
858
+ interactive=False
859
+ )
860
+
861
+ with gr.Row():
862
+ btn_reuse = gr.Button("♻️ Tái sử dụng văn bản", variant="secondary")
863
+
864
+ # TAB 3: Thông tin
865
+ with gr.Tab("ℹ️ Thông tin"):
866
+ gr.Markdown(f"""
867
+ ## 🎯 Về VieNeu-TTS
868
+
869
+ **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.
870
+
871
+ ### ⚙️ Cấu hình hiện tại
872
+ - **Model Backbone**: Q4 GGUF (llama-cpp)
873
+ - **Codec**: ONNX Decoder
874
+ - **Sample Rate**: {SAMPLE_RATE} Hz
875
+ - **Max Chunk Size**: {MAX_CHARS_PER_CHUNK} ký tự
876
+ - **Thư mục lịch sử**: `{HISTORY_DIR}`
877
+
878
+ ### 🎭 Giọng mẫu có sẵn
879
+ Hệ thống hỗ trợ **{len(VOICE_SAMPLES)}** giọng mẫu:
880
+ - **Nam miền Bắc**: Tuyên, Bình
881
+ - **Nam miền Nam**: Vĩnh, Nguyên, Sơn
882
+ - **Nữ miền Bắc**: Ngọc, Ly
883
+ - **Nữ miền Nam**: Đoan, Dung
884
+
885
+ ### 📌 Hướng dẫn sử dụng
886
+
887
+ 1. **Tổng hợp giọng nói**:
888
+ - Nhập văn bản vào ô "Văn bản"
889
+ - Chọn giọng mẫu phù hợp
890
+ - Nhấn "Bắt đầu tổng hợp"
891
+ - Đợi hệ thống xử lý và nghe kết quả
892
+
893
+ 2. **Xem lịch sử**:
894
+ - Vào tab "Lịch sử"
895
+ - Nhấn "Làm mới" để cập nhật danh sách
896
+ - Nhập số thứ tự và nhấn "Tải" để xem chi tiết
897
+
898
+ 3. **Tái sử dụng**:
899
+ - Trong tab Lịch sử, tải bản ghi cũ
900
+ - Nhấn "Tái sử dụng văn bản" để copy sang tab Tổng hợp
901
+
902
+ ### 🔧 Tính năng nâng cao
903
+ - ✅ Xử lý background (không phụ thuộc UI)
904
+ - ✅ Lưu lịch sử tự động
905
+ - ✅ Chia chunk thông minh
906
+ - ✅ Thread-safe operations
907
+ - ✅ Tự động xóa file cũ khi vượt quá 100 bản ghi
908
+
909
+ ### 📊 Thống kê
910
+ {get_processing_stats()}
911
+
912
+ ### 🔗 Liên kết
913
+ - [GitHub Repository](https://github.com/pnnbao97/VieNeu-TTS)
914
+ - [Model Hub](https://huggingface.co/pnnbao-ump)
915
+
916
+ ---
917
+ **Phiên bản**: 2.0 Enhanced | **Tác giả**: Phạm Nguyễn Ngọc Bảo
918
+ """)
919
+
920
+ # Event handlers - Tab Tổng hợp
921
+ btn_generate.click(
922
+ fn=synthesize_speech,
923
+ inputs=[text_input, voice_select],
924
+ outputs=[audio_output, status_output]
925
+ )
926
+
927
+ btn_clear.click(
928
+ fn=lambda: ("", None, "Đã xóa"),
929
+ outputs=[text_input, audio_output, status_output]
930
+ )
931
+
932
+ # Event handlers - Tab Lịch sử
933
+ btn_refresh.click(
934
+ fn=refresh_history,
935
+ outputs=history_display
936
+ ).then(
937
+ fn=get_processing_stats,
938
+ outputs=stats_display
939
+ )
940
+
941
+ btn_load_history.click(
942
+ fn=load_history_item,
943
+ inputs=history_select,
944
+ outputs=[history_audio, history_text, history_voice, history_info]
945
+ )
946
+
947
+ def clear_all():
948
+ msg = clear_all_history()
949
+ return get_history_list(), msg
950
+
951
+ btn_clear_all.click(
952
+ fn=clear_all,
953
+ outputs=[history_display, stats_display]
954
+ )
955
+
956
+ # Nút tái sử dụng văn bản
957
+ def reuse_text(text, voice):
958
+ return text, voice, "✅ Đã tải văn bản vào tab Tổng hợp"
959
+
960
+ btn_reuse.click(
961
+ fn=reuse_text,
962
+ inputs=[history_text, history_voice],
963
+ outputs=[text_input, voice_select, status_output]
964
+ )
965
+
966
+ if __name__ == "__main__":
967
+ print(f"\n{'='*60}")
968
+ print(f"🚀 VieNeu-TTS Studio đã sẵn sàng!")
969
+ print(f"📂 Lịch sử lưu tại: {HISTORY_DIR}")
970
+ print(f"🎭 Số giọng mẫu: {len(VOICE_SAMPLES)}")
971
+ print(f"⚙️ Chế độ: {DEVICE_INFO}")
972
+ print(f"{'='*60}\n")
973
+
974
+ demo.queue().launch(
975
+ server_name="0.0.0.0",
976
+ server_port=7860,
977
+ show_error=True
978
+ )
app-14.py ADDED
@@ -0,0 +1,1024 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import soundfile as sf
3
+ import tempfile
4
+ import os
5
+ import time
6
+ import numpy as np
7
+ import librosa
8
+ import re
9
+ import json
10
+ import shutil
11
+ from pathlib import Path
12
+ from datetime import datetime
13
+ from llama_cpp import Llama
14
+ from neucodec import NeuCodecOnnxDecoder
15
+ import torch
16
+ from utils.phonemize_text import phonemize_with_dict
17
+ import threading
18
+ from queue import Queue
19
+
20
+ print("⏳ Đang khởi động VieNeu-TTS...")
21
+
22
+ # --- CONSTANTS ---
23
+ MAX_CHARS_PER_CHUNK = 256
24
+ SAMPLE_RATE = 24000
25
+ DEVICE_INFO = "Q4 GGUF (llama-cpp) + ONNX Codec"
26
+
27
+ # Thư mục lưu lịch sử - sử dụng thư mục tạm nếu không có quyền ghi
28
+ try:
29
+ HISTORY_DIR = "./tts_history"
30
+ os.makedirs(HISTORY_DIR, exist_ok=True)
31
+ # Test write permission
32
+ test_file = os.path.join(HISTORY_DIR, ".test")
33
+ with open(test_file, 'w') as f:
34
+ f.write("test")
35
+ os.remove(test_file)
36
+ except (PermissionError, OSError):
37
+ # Fallback to temp directory
38
+ HISTORY_DIR = os.path.join(tempfile.gettempdir(), "vieneu_tts_history")
39
+ os.makedirs(HISTORY_DIR, exist_ok=True)
40
+ print(f"⚠️ Không có quyền ghi, sử dụng thư mục tạm: {HISTORY_DIR}")
41
+
42
+ HISTORY_JSON = os.path.join(HISTORY_DIR, "history.json")
43
+
44
+ # Đường dẫn model
45
+ BACKBONE_REPO = "pnnbao-ump/VieNeu-TTS-q8-gguf"
46
+ CODEC_REPO = "neuphonic/neucodec-onnx-decoder"
47
+
48
+ # Giọng mẫu
49
+ VOICE_SAMPLES = {
50
+ "Tuyên (nam miền Bắc)": {
51
+ "audio": "./sample/Tuyên (nam miền Bắc).wav",
52
+ "text": "./sample/Tuyên (nam miền Bắc).txt",
53
+ "codes": "./sample/Tuyên (nam miền Bắc).pt"
54
+ },
55
+ "Vĩnh (nam miền Nam)": {
56
+ "audio": "./sample/Vĩnh (nam miền Nam).wav",
57
+ "text": "./sample/Vĩnh (nam miền Nam).txt",
58
+ "codes": "./sample/Vĩnh (nam miền Nam).pt"
59
+ },
60
+ "Bình (nam miền Bắc)": {
61
+ "audio": "./sample/Bình (nam miền Bắc).wav",
62
+ "text": "./sample/Bình (nam miền Bắc).txt",
63
+ "codes": "./sample/Bình (nam miền Bắc).pt"
64
+ },
65
+ "Nguyên (nam miền Nam)": {
66
+ "audio": "./sample/Nguyên (nam miền Nam).wav",
67
+ "text": "./sample/Nguyên (nam miền Nam).txt",
68
+ "codes": "./sample/Nguyên (nam miền Nam).pt"
69
+ },
70
+ "Sơn (nam miền Nam)": {
71
+ "audio": "./sample/Sơn (nam miền Nam).wav",
72
+ "text": "./sample/Sơn (nam miền Nam).txt",
73
+ "codes": "./sample/Sơn (nam miền Nam).pt"
74
+ },
75
+ "Đoan (nữ miền Nam)": {
76
+ "audio": "./sample/Đoan (nữ miền Nam).wav",
77
+ "text": "./sample/Đoan (nữ miền Nam).txt",
78
+ "codes": "./sample/Đoan (nữ miền Nam).pt"
79
+ },
80
+ "Ngọc (nữ miền Bắc)": {
81
+ "audio": "./sample/Ngọc (nữ miền Bắc).wav",
82
+ "text": "./sample/Ngọc (nữ miền Bắc).txt",
83
+ "codes": "./sample/Ngọc (nữ miền Bắc).pt"
84
+ },
85
+ "Ly (nữ miền Bắc)": {
86
+ "audio": "./sample/Ly (nữ miền Bắc).wav",
87
+ "text": "./sample/Ly (nữ miền Bắc).txt",
88
+ "codes": "./sample/Ly (nữ miền Bắc).pt"
89
+ },
90
+ "Dung (nữ miền Nam)": {
91
+ "audio": "./sample/Dung (nữ miền Nam).wav",
92
+ "text": "./sample/Dung (nữ miền Nam).txt",
93
+ "codes": "./sample/Dung (nữ miền Nam).pt"
94
+ }
95
+ }
96
+
97
+ # --- HISTORY MANAGEMENT ---
98
+ history_lock = threading.Lock()
99
+
100
+ def load_history():
101
+ """Tải lịch sử từ file JSON - Thread-safe"""
102
+ with history_lock:
103
+ if os.path.exists(HISTORY_JSON):
104
+ try:
105
+ with open(HISTORY_JSON, 'r', encoding='utf-8') as f:
106
+ return json.load(f)
107
+ except Exception as e:
108
+ print(f"⚠️ Lỗi đọc history.json: {e}")
109
+ return []
110
+ return []
111
+
112
+ def save_history(history):
113
+ """Lưu lịch sử vào file JSON - Thread-safe"""
114
+ with history_lock:
115
+ try:
116
+ with open(HISTORY_JSON, 'w', encoding='utf-8') as f:
117
+ json.dump(history, f, ensure_ascii=False, indent=2)
118
+ except Exception as e:
119
+ print(f"⚠️ Lỗi ghi history.json: {e}")
120
+
121
+ def add_to_history(text, voice, audio_path, duration, status):
122
+ """Thêm một bản ghi vào lịch sử - Thread-safe"""
123
+ try:
124
+ history = load_history()
125
+
126
+ # Tạo tên file duy nhất
127
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
128
+ filename = f"tts_{timestamp}.wav"
129
+ permanent_path = os.path.join(HISTORY_DIR, filename)
130
+
131
+ # Copy file audio vào thư mục lịch sử
132
+ if audio_path and os.path.exists(audio_path):
133
+ try:
134
+ shutil.copy2(audio_path, permanent_path)
135
+ except Exception as e:
136
+ print(f"⚠️ Không thể copy file audio: {e}")
137
+ permanent_path = audio_path # Dùng file tạm nếu không copy được
138
+ else:
139
+ permanent_path = None
140
+
141
+ record = {
142
+ "id": timestamp,
143
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
144
+ "text": text[:100] + "..." if len(text) > 100 else text,
145
+ "full_text": text,
146
+ "voice": voice,
147
+ "audio_path": permanent_path,
148
+ "duration": duration,
149
+ "status": status
150
+ }
151
+
152
+ history.insert(0, record) # Thêm vào đầu danh sách
153
+
154
+ # Giới hạn lịch sử tối đa 100 bản ghi
155
+ if len(history) > 100:
156
+ # Xóa file audio cũ
157
+ old_record = history.pop()
158
+ try:
159
+ if old_record.get('audio_path') and os.path.exists(old_record['audio_path']):
160
+ os.remove(old_record['audio_path'])
161
+ except Exception as e:
162
+ print(f"⚠️ Không thể xóa file cũ: {e}")
163
+
164
+ save_history(history)
165
+ return permanent_path
166
+ except Exception as e:
167
+ print(f"⚠️ Lỗi khi lưu lịch sử: {e}")
168
+ import traceback
169
+ traceback.print_exc()
170
+ return audio_path if audio_path else None
171
+
172
+ def get_history_list():
173
+ """Lấy danh sách lịch sử để hiển thị dạng HTML"""
174
+ history = load_history()
175
+ if not history:
176
+ return """
177
+ <div style='padding: 20px; text-align: center; color: #64748b;'>
178
+ <p style='font-size: 1.1em;'>📭 Chưa có lịch sử tổng hợp nào</p>
179
+ <p style='font-size: 0.9em;'>Các bản ghi sẽ xuất hiện ở đây sau khi bạn tổng hợp giọng nói</p>
180
+ </div>
181
+ """
182
+
183
+ html_parts = ["<div style='font-family: system-ui; line-height: 1.6;'>"]
184
+
185
+ for i, record in enumerate(history[:50], 1): # Hiển thị 50 bản ghi gần nhất
186
+ status_color = "#10b981" if record['status'] == "Thành công" else "#ef4444"
187
+ status_icon = "✅" if record['status'] == "Thành công" else "❌"
188
+
189
+ html_parts.append(f"""
190
+ <div style='
191
+ background: white;
192
+ border: 1px solid #e2e8f0;
193
+ border-radius: 8px;
194
+ padding: 15px;
195
+ margin-bottom: 12px;
196
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
197
+ '>
198
+ <div style='display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;'>
199
+ <div style='font-weight: 600; color: #1e293b; font-size: 0.95em;'>
200
+ <span style='color: #64748b;'>#{i}</span> {record['voice']}
201
+ </div>
202
+ <div style='font-size: 0.85em; color: #64748b;'>
203
+ {record['timestamp']}
204
+ </div>
205
+ </div>
206
+
207
+ <div style='
208
+ background: #f8fafc;
209
+ padding: 10px;
210
+ border-radius: 6px;
211
+ margin-bottom: 8px;
212
+ color: #334155;
213
+ font-size: 0.9em;
214
+ border-left: 3px solid #3b82f6;
215
+ '>
216
+ {record['text']}
217
+ </div>
218
+
219
+ <div style='display: flex; gap: 20px; font-size: 0.85em; color: #64748b;'>
220
+ <div>⏱️ {record['duration']:.2f}s</div>
221
+ <div style='color: {status_color}; font-weight: 500;'>
222
+ {status_icon} {record['status']}
223
+ </div>
224
+ <div style='margin-left: auto; color: #3b82f6; cursor: pointer;'>
225
+ ID: {record['id'][:13]}...
226
+ </div>
227
+ </div>
228
+ </div>
229
+ """)
230
+
231
+ html_parts.append("</div>")
232
+ return "".join(html_parts)
233
+
234
+ def delete_history_item(item_id):
235
+ """Xóa một item khỏi lịch sử"""
236
+ history = load_history()
237
+ new_history = []
238
+ deleted = False
239
+
240
+ for record in history:
241
+ if record['id'] == item_id:
242
+ # Xóa file audio
243
+ try:
244
+ if record.get('audio_path') and os.path.exists(record['audio_path']):
245
+ os.remove(record['audio_path'])
246
+ except Exception as e:
247
+ print(f"⚠️ Không thể xóa file: {e}")
248
+ deleted = True
249
+ else:
250
+ new_history.append(record)
251
+
252
+ if deleted:
253
+ save_history(new_history)
254
+ return True, "✅ Đã xóa bản ghi"
255
+ return False, "❌ Không tìm thấy bản ghi"
256
+
257
+ def clear_all_history():
258
+ """Xóa toàn bộ lịch sử"""
259
+ history = load_history()
260
+
261
+ # Xóa tất cả file audio
262
+ for record in history:
263
+ try:
264
+ if record.get('audio_path') and os.path.exists(record['audio_path']):
265
+ os.remove(record['audio_path'])
266
+ except Exception as e:
267
+ print(f"⚠️ Không thể xóa file: {e}")
268
+
269
+ # Xóa file JSON
270
+ save_history([])
271
+ return "✅ Đã xóa toàn bộ lịch sử"
272
+
273
+ # --- BACKGROUND PROCESSING QUEUE ---
274
+ processing_queue = Queue()
275
+ is_processing = False
276
+ processing_stats = {"total": 0, "success": 0, "failed": 0}
277
+
278
+ def background_processor():
279
+ """Xử lý queue tổng hợp trong background"""
280
+ global is_processing, processing_stats
281
+
282
+ while True:
283
+ task = processing_queue.get()
284
+ if task is None:
285
+ break
286
+
287
+ is_processing = True
288
+ text, voice = task
289
+
290
+ try:
291
+ print(f"[Background] Bắt đầu tổng hợp: {text[:50]}...")
292
+ result = synthesize_speech_internal(text, voice)
293
+
294
+ if result:
295
+ processing_stats["success"] += 1
296
+ print(f"[Background] ✅ Hoàn thành: {result}")
297
+ else:
298
+ processing_stats["failed"] += 1
299
+ print(f"[Background] ❌ Thất bại")
300
+
301
+ processing_stats["total"] += 1
302
+
303
+ except Exception as e:
304
+ processing_stats["failed"] += 1
305
+ processing_stats["total"] += 1
306
+ print(f"[Background] ❌ Lỗi: {e}")
307
+ import traceback
308
+ traceback.print_exc()
309
+
310
+ is_processing = False
311
+ processing_queue.task_done()
312
+
313
+ # Khởi động background thread
314
+ bg_thread = threading.Thread(target=background_processor, daemon=True)
315
+ bg_thread.start()
316
+
317
+ def get_processing_stats():
318
+ """Lấy thống kê xử lý"""
319
+ return f"📊 Tổng: {processing_stats['total']} | ✅ Thành công: {processing_stats['success']} | ❌ Thất bại: {processing_stats['failed']}"
320
+
321
+ def split_text_into_chunks(text, max_chars=256):
322
+ """Chia text thành chunks"""
323
+ sentences = re.split(r'([.!?,;])', text)
324
+ chunks = []
325
+ current = ""
326
+
327
+ for i in range(0, len(sentences), 2):
328
+ sentence = sentences[i]
329
+ punct = sentences[i+1] if i+1 < len(sentences) else ""
330
+ segment = sentence + punct
331
+
332
+ if len(current) + len(segment) <= max_chars:
333
+ current += segment
334
+ else:
335
+ if current:
336
+ chunks.append(current.strip())
337
+ current = segment
338
+
339
+ if current:
340
+ chunks.append(current.strip())
341
+
342
+ return chunks if chunks else [text]
343
+
344
+ def decode_audio(codes_str, codec):
345
+ """Decode speech tokens to audio"""
346
+ speech_ids = [int(num) for num in re.findall(r"<\|speech_(\d+)\|>", codes_str)]
347
+
348
+ if len(speech_ids) == 0:
349
+ print("Không tìm thấy mã giọng nói hợp lệ.")
350
+ return np.array([], dtype=np.float32)
351
+
352
+ codes = np.array(speech_ids, dtype=np.int32)[np.newaxis, np.newaxis, :]
353
+ recon = codec.decode_code(codes)
354
+ return recon[0, 0, :]
355
+
356
+ # --- MODEL LOADING ---
357
+ # Sử dụng class để giữ state của model
358
+ class ModelManager:
359
+ def __init__(self):
360
+ self.backbone = None
361
+ self.codec = None
362
+ self.model_loaded = False
363
+ self.loading_lock = threading.Lock()
364
+ self.load_models()
365
+
366
+ def load_models(self):
367
+ """Tải models với thread safety"""
368
+ with self.loading_lock:
369
+ if self.model_loaded:
370
+ print("✅ Models đã được tải trước đó")
371
+ return True
372
+
373
+ print("📦 Đang tải model Q4 GGUF và Codec ONNX...")
374
+ try:
375
+ self.backbone = Llama.from_pretrained(
376
+ repo_id=BACKBONE_REPO,
377
+ filename="*.gguf",
378
+ verbose=False,
379
+ n_gpu_layers=-1,
380
+ n_ctx=2048,
381
+ mlock=True,
382
+ flash_attn=True,
383
+ )
384
+
385
+ self.codec = NeuCodecOnnxDecoder.from_pretrained(CODEC_REPO)
386
+
387
+ self.model_loaded = True
388
+ print("✅ Model đã tải thành công!")
389
+ return True
390
+
391
+ except Exception as e:
392
+ import traceback
393
+ traceback.print_exc()
394
+ print(f"❌ Lỗi khi tải model: {e}")
395
+ self.model_loaded = False
396
+ return False
397
+
398
+ def is_ready(self):
399
+ """Kiểm tra xem model đã sẵn sàng chưa"""
400
+ return self.model_loaded and self.backbone is not None and self.codec is not None
401
+
402
+ # Khởi tạo ModelManager singleton
403
+ model_manager = ModelManager()
404
+
405
+ # --- SYNTHESIS FUNCTION (Internal) ---
406
+ def synthesize_speech_internal(text, voice_choice):
407
+ """Internal synthesis function không phụ thuộc UI"""
408
+
409
+ if not model_manager.is_ready():
410
+ print("❌ Model chưa được tải, đang thử tải lại...")
411
+ if not model_manager.load_models():
412
+ print("❌ Không thể tải model")
413
+ return None
414
+
415
+ if not text or text.strip() == "":
416
+ print("❌ Text rỗng")
417
+ return None
418
+
419
+ if voice_choice not in VOICE_SAMPLES:
420
+ print(f"❌ Giọng không hợp lệ: {voice_choice}")
421
+ return None
422
+
423
+ raw_text = text.strip()
424
+
425
+ # Load reference text
426
+ ref_text_path = VOICE_SAMPLES[voice_choice]["text"]
427
+ try:
428
+ with open(ref_text_path, "r", encoding="utf-8") as f:
429
+ ref_text_raw = f.read()
430
+ except Exception as e:
431
+ print(f"❌ Lỗi đọc file text mẫu: {e}")
432
+ return None
433
+
434
+ # Tải codes
435
+ ref_codes = None
436
+ codes_path = VOICE_SAMPLES[voice_choice]["codes"]
437
+
438
+ try:
439
+ ref_codes_tensor = torch.load(codes_path, map_location="cpu")
440
+ if isinstance(ref_codes_tensor, torch.Tensor):
441
+ ref_codes = ref_codes_tensor.cpu().numpy()
442
+ else:
443
+ ref_codes = np.array(ref_codes_tensor)
444
+ except Exception as e:
445
+ print(f"❌ Lỗi khi tải codes: {e}")
446
+ return None
447
+
448
+ if ref_codes is None or len(ref_codes) == 0:
449
+ print("❌ Codes rỗng")
450
+ return None
451
+
452
+ # Split text
453
+ text_chunks = split_text_into_chunks(raw_text, max_chars=MAX_CHARS_PER_CHUNK)
454
+
455
+ all_audio_segments = []
456
+ silence_pad = np.zeros(int(SAMPLE_RATE * 0.15), dtype=np.float32)
457
+
458
+ start_time = time.time()
459
+
460
+ try:
461
+ for i, chunk in enumerate(text_chunks):
462
+ print(f"[Internal] Xử lý chunk {i+1}/{len(text_chunks)}")
463
+
464
+ # Phonemize
465
+ ref_text_phoneme = phonemize_with_dict(ref_text_raw)
466
+ input_text_phoneme = phonemize_with_dict(chunk)
467
+
468
+ # Create prompt
469
+ codes_str = "".join([f"<|speech_{idx}|>" for idx in ref_codes])
470
+
471
+ prompt = (
472
+ f"user: Convert the text to speech:<|TEXT_PROMPT_START|>{ref_text_phoneme} {input_text_phoneme}"
473
+ f"<|TEXT_PROMPT_END|>\nassistant:<|SPEECH_GENERATION_START|>{codes_str}"
474
+ )
475
+
476
+ # Generate
477
+ output = model_manager.backbone(
478
+ prompt,
479
+ max_tokens=2048,
480
+ temperature=1.0,
481
+ top_k=50,
482
+ stop=["<|SPEECH_GENERATION_END|>"],
483
+ )
484
+ output_str = output["choices"][0]["text"]
485
+
486
+ # Decode
487
+ chunk_wav = decode_audio(output_str, model_manager.codec)
488
+
489
+ if chunk_wav is not None and len(chunk_wav) > 0:
490
+ all_audio_segments.append(chunk_wav)
491
+ if i < len(text_chunks) - 1:
492
+ all_audio_segments.append(silence_pad)
493
+
494
+ if not all_audio_segments:
495
+ print("❌ Không có audio segment nào")
496
+ add_to_history(raw_text, voice_choice, None, 0, "Lỗi: Không sinh được audio")
497
+ return None
498
+
499
+ final_wav = np.concatenate(all_audio_segments)
500
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
501
+ sf.write(tmp.name, final_wav, SAMPLE_RATE)
502
+ output_path = tmp.name
503
+
504
+ process_time = time.time() - start_time
505
+
506
+ # Lưu vào lịch sử
507
+ permanent_path = add_to_history(raw_text, voice_choice, output_path, process_time, "Thành công")
508
+
509
+ print(f"✅ Hoàn thành: {permanent_path}")
510
+ return permanent_path
511
+
512
+ except Exception as e:
513
+ import traceback
514
+ traceback.print_exc()
515
+ add_to_history(raw_text, voice_choice, None, 0, f"Lỗi: {str(e)}")
516
+ return None
517
+
518
+ # --- SYNTHESIS FUNCTION (UI) ---
519
+ def synthesize_speech(text, voice_choice):
520
+ """Main synthesis function với UI feedback"""
521
+
522
+ if not model_manager.is_ready():
523
+ yield None, "⚠️ Model chưa tải. Đang thử tải lại..."
524
+ if not model_manager.load_models():
525
+ yield None, "❌ Không thể tải model. Vui lòng kiểm tra console!"
526
+ return
527
+ yield None, "✅ Model đã tải thành công!"
528
+
529
+ if not text or text.strip() == "":
530
+ yield None, "⚠️ Vui lòng nhập văn bản!"
531
+ return
532
+
533
+ if voice_choice not in VOICE_SAMPLES:
534
+ yield None, "⚠️ Vui lòng chọn giọng mẫu."
535
+ return
536
+
537
+ raw_text = text.strip()
538
+
539
+ # Load reference text
540
+ ref_text_path = VOICE_SAMPLES[voice_choice]["text"]
541
+ try:
542
+ with open(ref_text_path, "r", encoding="utf-8") as f:
543
+ ref_text_raw = f.read()
544
+ except Exception as e:
545
+ yield None, f"❌ Lỗi đọc file text mẫu: {e}"
546
+ return
547
+
548
+ yield None, "📄 Đang xử lý Reference..."
549
+
550
+ # Tải codes
551
+ ref_codes = None
552
+ codes_path = VOICE_SAMPLES[voice_choice]["codes"]
553
+
554
+ try:
555
+ ref_codes_tensor = torch.load(codes_path, map_location="cpu")
556
+ if isinstance(ref_codes_tensor, torch.Tensor):
557
+ ref_codes = ref_codes_tensor.cpu().numpy()
558
+ else:
559
+ ref_codes = np.array(ref_codes_tensor)
560
+ except Exception as e:
561
+ yield None, f"❌ Lỗi khi tải codes: {e}"
562
+ return
563
+
564
+ if ref_codes is None or len(ref_codes) == 0:
565
+ yield None, "❌ Codes tham chiếu không hợp lệ."
566
+ return
567
+
568
+ # Split text
569
+ text_chunks = split_text_into_chunks(raw_text, max_chars=MAX_CHARS_PER_CHUNK)
570
+ total_chunks = len(text_chunks)
571
+
572
+ yield None, f"🚀 Bắt đầu tổng hợp ({total_chunks} đoạn)..."
573
+
574
+ all_audio_segments = []
575
+ silence_pad = np.zeros(int(SAMPLE_RATE * 0.15), dtype=np.float32)
576
+
577
+ start_time = time.time()
578
+
579
+ try:
580
+ for i, chunk in enumerate(text_chunks):
581
+ progress = int((i/total_chunks)*100)
582
+ yield None, f"⏳ Đang xử lý đoạn {i+1}/{total_chunks}... ({progress}%)"
583
+
584
+ # Phonemize
585
+ ref_text_phoneme = phonemize_with_dict(ref_text_raw)
586
+ input_text_phoneme = phonemize_with_dict(chunk)
587
+
588
+ # Create prompt
589
+ codes_str = "".join([f"<|speech_{idx}|>" for idx in ref_codes])
590
+
591
+ prompt = (
592
+ f"user: Convert the text to speech:<|TEXT_PROMPT_START|>{ref_text_phoneme} {input_text_phoneme}"
593
+ f"<|TEXT_PROMPT_END|>\nassistant:<|SPEECH_GENERATION_START|>{codes_str}"
594
+ )
595
+
596
+ # Generate
597
+ output = model_manager.backbone(
598
+ prompt,
599
+ max_tokens=2048,
600
+ temperature=1.0,
601
+ top_k=50,
602
+ stop=["<|SPEECH_GENERATION_END|>"],
603
+ )
604
+ output_str = output["choices"][0]["text"]
605
+
606
+ # Decode
607
+ chunk_wav = decode_audio(output_str, model_manager.codec)
608
+
609
+ if chunk_wav is not None and len(chunk_wav) > 0:
610
+ all_audio_segments.append(chunk_wav)
611
+ if i < total_chunks - 1:
612
+ all_audio_segments.append(silence_pad)
613
+
614
+ if not all_audio_segments:
615
+ yield None, "❌ Không sinh được audio nào."
616
+ add_to_history(raw_text, voice_choice, None, 0, "Lỗi: Không sinh được audio")
617
+ return
618
+
619
+ yield None, "💾 Đang ghép file và lưu..."
620
+
621
+ final_wav = np.concatenate(all_audio_segments)
622
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
623
+ sf.write(tmp.name, final_wav, SAMPLE_RATE)
624
+ output_path = tmp.name
625
+
626
+ process_time = time.time() - start_time
627
+ audio_duration = len(final_wav) / SAMPLE_RATE
628
+ rtf = process_time / audio_duration
629
+
630
+ # Lưu vào lịch sử
631
+ permanent_path = add_to_history(raw_text, voice_choice, output_path, process_time, "Thành công")
632
+
633
+ yield permanent_path, f"✅ Hoàn tất! (Thời gian: {process_time:.2f}s | Audio: {audio_duration:.2f}s | RTF: {rtf:.3f})"
634
+
635
+ except Exception as e:
636
+ import traceback
637
+ traceback.print_exc()
638
+ add_to_history(raw_text, voice_choice, None, 0, f"Lỗi: {str(e)}")
639
+ yield None, f"❌ Lỗi tổng hợp: {str(e)}"
640
+
641
+ def refresh_history():
642
+ """Làm mới danh sách lịch sử"""
643
+ return get_history_list()
644
+
645
+ def load_history_item(item_index):
646
+ """Tải một item từ lịch sử theo số thứ tự"""
647
+ if not item_index or item_index.strip() == "":
648
+ return None, "", "", "⚠️ Vui lòng nhập số thứ tự"
649
+
650
+ try:
651
+ index = int(item_index.strip()) - 1
652
+ history = load_history()
653
+
654
+ if index < 0 or index >= len(history):
655
+ return None, "", "", f"❌ Số thứ tự không hợp lệ (1-{len(history)})"
656
+
657
+ record = history[index]
658
+
659
+ audio_path = None
660
+ if record.get('audio_path') and os.path.exists(record['audio_path']):
661
+ audio_path = record['audio_path']
662
+
663
+ info = f"""
664
+ 📅 Thời gian: {record['timestamp']}
665
+ ⏱️ Thời lượng: {record['duration']:.2f}s
666
+ 🎭 Giọng: {record['voice']}
667
+ 📊 Trạng thái: {record['status']}
668
+ 🆔 ID: {record['id']}
669
+ """.strip()
670
+
671
+ return audio_path, record['full_text'], record['voice'], info
672
+
673
+ except ValueError:
674
+ return None, "", "", "❌ Vui lòng nhập số hợp lệ"
675
+ except Exception as e:
676
+ return None, "", "", f"❌ Lỗi: {str(e)}"
677
+
678
+ # --- UI SETUP ---
679
+ theme = gr.themes.Ocean(
680
+ primary_hue="indigo",
681
+ secondary_hue="cyan",
682
+ neutral_hue="slate",
683
+ font=[gr.themes.GoogleFont('Inter'), 'ui-sans-serif', 'system-ui'],
684
+ ).set(
685
+ button_primary_background_fill="linear-gradient(90deg, #6366f1 0%, #0ea5e9 100%)",
686
+ button_primary_background_fill_hover="linear-gradient(90deg, #4f46e5 0%, #0284c7 100%)",
687
+ )
688
+
689
+ css = """
690
+ .container { max-width: 1400px; margin: auto; }
691
+ .header-box {
692
+ text-align: center;
693
+ margin-bottom: 25px;
694
+ padding: 25px;
695
+ background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
696
+ border-radius: 12px;
697
+ color: white;
698
+ }
699
+ .header-title {
700
+ font-size: 2.5rem;
701
+ font-weight: 800;
702
+ }
703
+ .gradient-text {
704
+ background: -webkit-linear-gradient(45deg, #60A5FA, #22D3EE);
705
+ -webkit-background-clip: text;
706
+ -webkit-text-fill-color: transparent;
707
+ }
708
+ .status-box {
709
+ font-weight: bold;
710
+ text-align: center;
711
+ border: none;
712
+ background: transparent;
713
+ }
714
+ .model-card-content {
715
+ display: flex;
716
+ flex-wrap: wrap;
717
+ justify-content: center;
718
+ align-items: center;
719
+ gap: 15px;
720
+ font-size: 0.9rem;
721
+ color: #cbd5e1;
722
+ }
723
+ .model-card-item {
724
+ display: flex;
725
+ align-items: center;
726
+ justify-content: center;
727
+ gap: 6px;
728
+ color: #94a3b8;
729
+ }
730
+ .model-card-link {
731
+ color: #3b82f6;
732
+ text-decoration: none;
733
+ font-weight: 500;
734
+ transition: color 0.2s;
735
+ }
736
+ .model-card-link:hover {
737
+ color: #2563eb;
738
+ text-decoration: underline;
739
+ }
740
+ .history-container {
741
+ max-height: 650px;
742
+ overflow-y: auto;
743
+ padding: 10px;
744
+ background: #f1f5f9;
745
+ border-radius: 8px;
746
+ }
747
+ .info-box {
748
+ background: #f8fafc;
749
+ padding: 12px;
750
+ border-radius: 6px;
751
+ border-left: 4px solid #3b82f6;
752
+ margin: 10px 0;
753
+ }
754
+ """
755
+
756
+ EXAMPLES_LIST = [
757
+ ["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)"],
758
+ ["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)"],
759
+ ["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)"],
760
+ ]
761
+
762
+ initial_status = f"✅ Model đã tải thành công! (Chạy trên **{DEVICE_INFO}**). Hỗ trợ xử lý background và lưu lịch sử." if model_manager.is_ready() else "⚠️ Model đang được tải hoặc chưa sẵn sàng..."
763
+
764
+ with gr.Blocks(title="VieNeu-TTS", theme=theme, css=css) as demo:
765
+ with gr.Column(elem_classes="container"):
766
+ gr.HTML(f"""
767
+ <div class="header-box">
768
+ <h1 class="header-title">
769
+ <span class="header-icon">🦜</span>
770
+ <span class="gradient-text">VieNeu-TTS Studio</span>
771
+ </h1>
772
+ <p style="margin: 10px 0;">Chế độ: {DEVICE_INFO} | Background Processing ✅ | History ✅</p>
773
+ <p style="font-size: 0.85rem; color: #94a3b8;">📁 Lịch sử lưu tại: {HISTORY_DIR}</p>
774
+ <div class="model-card-content">
775
+ <div class="model-card-item">
776
+ <strong>Repository:</strong>
777
+ <a href="https://github.com/pnnbao97/VieNeu-TTS" target="_blank" class="model-card-link">GitHub</a>
778
+ </div>
779
+ <div class="model-card-item">
780
+ <strong>Tác giả:</strong>
781
+ <span>Phạm Nguyễn Ngọc Bảo</span>
782
+ </div>
783
+ </div>
784
+ </div>
785
+ """)
786
+
787
+ status_banner = gr.Markdown(initial_status)
788
+
789
+ # Thêm nút kiểm tra model status
790
+ with gr.Row():
791
+ btn_check_model = gr.Button("🔍 Kiểm tra trạng thái Model", size="sm", variant="secondary")
792
+ model_status_output = gr.Textbox(label="", show_label=False, interactive=False, container=False)
793
+
794
+ def check_model_status():
795
+ if model_manager.is_ready():
796
+ return f"✅ Model sẵn sàng | Backbone: {'Loaded' if model_manager.backbone else 'Not loaded'} | Codec: {'Loaded' if model_manager.codec else 'Not loaded'}"
797
+ else:
798
+ return "⚠️ Model chưa sẵn sàng. Nhấn nút tổng hợp để tự động tải model."
799
+
800
+ btn_check_model.click(
801
+ fn=check_model_status,
802
+ outputs=model_status_output
803
+ )
804
+
805
+ # --- TABS ---
806
+ with gr.Tabs():
807
+ # TAB 1: Tổng hợp
808
+ with gr.Tab("🎙️ Tổng hợp"):
809
+ with gr.Row(elem_classes="container"):
810
+ with gr.Column(scale=3):
811
+ text_input = gr.Textbox(
812
+ label=f"📝 Văn bản (Chia chunk: {MAX_CHARS_PER_CHUNK} ký tự)",
813
+ lines=7,
814
+ placeholder="Nhập văn bản tiếng Việt cần tổng hợp...",
815
+ 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. Bước chân trên những con phố cổ kính quanh Hồ Hoàn Kiếm, du khách như được du hành ngược thời gian.",
816
+ )
817
+
818
+ voice_select = gr.Dropdown(
819
+ choices=list(VOICE_SAMPLES.keys()),
820
+ value=list(VOICE_SAMPLES.keys())[0],
821
+ label="👤 Chọn giọng mẫu",
822
+ )
823
+
824
+ with gr.Row():
825
+ btn_generate = gr.Button("🎵 Bắt đầu tổng hợp", variant="primary", size="lg")
826
+ btn_clear = gr.Button("🗑️ Xóa", variant="secondary", size="lg")
827
+
828
+ with gr.Column(scale=2):
829
+ audio_output = gr.Audio(
830
+ label="🔊 Kết quả",
831
+ type="filepath",
832
+ autoplay=True
833
+ )
834
+ status_output = gr.Textbox(
835
+ label="📊 Trạng thái",
836
+ elem_classes="status-box",
837
+ value="Chờ nhập văn bản...",
838
+ lines=2
839
+ )
840
+
841
+ gr.Examples(
842
+ examples=EXAMPLES_LIST,
843
+ inputs=[text_input, voice_select],
844
+ outputs=[audio_output, status_output],
845
+ fn=synthesize_speech,
846
+ cache_examples=False,
847
+ label="💡 Các ví dụ nhanh"
848
+ )
849
+
850
+ # TAB 2: Lịch sử
851
+ with gr.Tab("📜 Lịch sử"):
852
+ with gr.Row():
853
+ with gr.Column(scale=3):
854
+ gr.Markdown("### 📋 Danh sách lịch sử")
855
+
856
+ with gr.Row():
857
+ btn_refresh = gr.Button("🔄 Làm mới", size="sm", variant="secondary")
858
+ btn_clear_all = gr.Button("🗑️ Xóa toàn bộ", size="sm", variant="stop")
859
+ stats_display = gr.Textbox(
860
+ value=get_processing_stats(),
861
+ label="",
862
+ show_label=False,
863
+ interactive=False,
864
+ container=False
865
+ )
866
+
867
+ history_display = gr.HTML(
868
+ value=get_history_list(),
869
+ elem_classes="history-container"
870
+ )
871
+
872
+ with gr.Column(scale=2):
873
+ gr.Markdown("### 🔍 Chi tiết bản ghi")
874
+
875
+ with gr.Row():
876
+ history_select = gr.Textbox(
877
+ label="Nhập số thứ tự (vd: 1, 2, 3...)",
878
+ placeholder="Nhập số...",
879
+ scale=3
880
+ )
881
+ btn_load_history = gr.Button("📂 Tải", variant="primary", scale=1)
882
+
883
+ history_info = gr.Textbox(
884
+ label="ℹ️ Thông tin",
885
+ lines=6,
886
+ elem_classes="info-box"
887
+ )
888
+
889
+ history_audio = gr.Audio(
890
+ label="🔊 Audio",
891
+ type="filepath"
892
+ )
893
+
894
+ history_voice = gr.Textbox(
895
+ label="🎭 Giọng đã dùng",
896
+ interactive=False
897
+ )
898
+
899
+ history_text = gr.Textbox(
900
+ label="📄 Văn bản đầy đủ",
901
+ lines=5,
902
+ interactive=False
903
+ )
904
+
905
+ with gr.Row():
906
+ btn_reuse = gr.Button("♻️ Tái sử dụng văn bản", variant="secondary")
907
+
908
+ # TAB 3: Thông tin
909
+ with gr.Tab("ℹ️ Thông tin"):
910
+ gr.Markdown(f"""
911
+ ## 🎯 Về VieNeu-TTS
912
+
913
+ **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.
914
+
915
+ ### ⚙️ Cấu hình hiện tại
916
+ - **Model Backbone**: Q4 GGUF (llama-cpp)
917
+ - **Codec**: ONNX Decoder
918
+ - **Sample Rate**: {SAMPLE_RATE} Hz
919
+ - **Max Chunk Size**: {MAX_CHARS_PER_CHUNK} ký tự
920
+ - **Thư mục lịch sử**: `{HISTORY_DIR}`
921
+
922
+ ### 🎭 Giọng mẫu có sẵn
923
+ Hệ thống hỗ trợ **{len(VOICE_SAMPLES)}** giọng mẫu:
924
+ - **Nam miền Bắc**: Tuyên, Bình
925
+ - **Nam miền Nam**: Vĩnh, Nguyên, Sơn
926
+ - **Nữ miền Bắc**: Ngọc, Ly
927
+ - **Nữ miền Nam**: Đoan, Dung
928
+
929
+ ### 📌 Hướng dẫn sử dụng
930
+
931
+ 1. **Tổng hợp giọng nói**:
932
+ - Nhập văn bản vào ô "Văn bản"
933
+ - Chọn giọng mẫu phù hợp
934
+ - Nhấn "Bắt đầu tổng hợp"
935
+ - Đợi hệ thống xử lý và nghe kết quả
936
+
937
+ 2. **Xem lịch sử**:
938
+ - Vào tab "Lịch sử"
939
+ - Nhấn "Làm mới" để cập nhật danh sách
940
+ - Nhập số thứ tự và nhấn "Tải" để xem chi tiết
941
+
942
+ 3. **Tái sử dụng**:
943
+ - Trong tab Lịch sử, tải bản ghi cũ
944
+ - Nhấn "Tái sử dụng văn bản" để copy sang tab Tổng hợp
945
+
946
+ ### 🔧 Tính năng nâng cao
947
+ - ✅ Xử lý background (không phụ thuộc UI)
948
+ - ✅ Lưu lịch sử tự động
949
+ - ✅ Chia chunk thông minh
950
+ - ✅ Thread-safe operations
951
+ - ✅ Tự động xóa file cũ khi vượt quá 100 bản ghi
952
+ - ✅ Auto-reload model sau khi reload trang
953
+
954
+ ### 📊 Thống kê
955
+ {get_processing_stats()}
956
+
957
+ ### 🔗 Liên kết
958
+ - [GitHub Repository](https://github.com/pnnbao97/VieNeu-TTS)
959
+ - [Model Hub](https://huggingface.co/pnnbao-ump)
960
+
961
+ ---
962
+ **Phiên bản**: 2.0 Enhanced | **Tác giả**: Phạm Nguyễn Ngọc Bảo
963
+ """)
964
+
965
+ # Event handlers - Tab Tổng hợp
966
+ btn_generate.click(
967
+ fn=synthesize_speech,
968
+ inputs=[text_input, voice_select],
969
+ outputs=[audio_output, status_output]
970
+ )
971
+
972
+ btn_clear.click(
973
+ fn=lambda: ("", None, "Đã xóa"),
974
+ outputs=[text_input, audio_output, status_output]
975
+ )
976
+
977
+ # Event handlers - Tab Lịch sử
978
+ btn_refresh.click(
979
+ fn=refresh_history,
980
+ outputs=history_display
981
+ ).then(
982
+ fn=get_processing_stats,
983
+ outputs=stats_display
984
+ )
985
+
986
+ btn_load_history.click(
987
+ fn=load_history_item,
988
+ inputs=history_select,
989
+ outputs=[history_audio, history_text, history_voice, history_info]
990
+ )
991
+
992
+ def clear_all():
993
+ msg = clear_all_history()
994
+ return get_history_list(), msg
995
+
996
+ btn_clear_all.click(
997
+ fn=clear_all,
998
+ outputs=[history_display, stats_display]
999
+ )
1000
+
1001
+ # Nút tái sử dụng văn bản
1002
+ def reuse_text(text, voice):
1003
+ return text, voice, "✅ Đã tải văn bản vào tab Tổng hợp"
1004
+
1005
+ btn_reuse.click(
1006
+ fn=reuse_text,
1007
+ inputs=[history_text, history_voice],
1008
+ outputs=[text_input, voice_select, status_output]
1009
+ )
1010
+
1011
+ if __name__ == "__main__":
1012
+ print(f"\n{'='*60}")
1013
+ print(f"🚀 VieNeu-TTS Studio đã sẵn sàng!")
1014
+ print(f"📂 Lịch sử lưu tại: {HISTORY_DIR}")
1015
+ print(f"🎭 Số giọng mẫu: {len(VOICE_SAMPLES)}")
1016
+ print(f"⚙️ Chế độ: {DEVICE_INFO}")
1017
+ print(f"✅ Model status: {'Ready' if model_manager.is_ready() else 'Not ready'}")
1018
+ print(f"{'='*60}\n")
1019
+
1020
+ demo.queue().launch(
1021
+ server_name="0.0.0.0",
1022
+ server_port=7860,
1023
+ show_error=True
1024
+ )