Arrcttacsrks commited on
Commit
6ba21aa
·
verified ·
1 Parent(s): 9df1d6b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +707 -858
app.py CHANGED
@@ -16,205 +16,348 @@ import torch
16
  from utils.phonemize_text import phonemize_with_dict
17
  import threading
18
  from queue import Queue
19
- from dataclasses import dataclass, asdict
20
- from typing import Optional, Dict, List, Tuple
21
- import hashlib
22
 
23
- print("⏳ Đang khởi động VieNeu-TTS Enhanced v3.0...")
24
 
25
  # --- CONSTANTS ---
26
  MAX_CHARS_PER_CHUNK = 256
27
  SAMPLE_RATE = 24000
28
  DEVICE_INFO = "Q4 GGUF (llama-cpp) + ONNX Codec"
29
- VERSION = "3.0 Enhanced"
30
 
31
- # --- DATA CLASSES ---
32
- @dataclass
33
- class VoiceSettings:
34
- """Cài đặt tùy chỉnh giọng nói"""
35
- temperature: float = 1.0
36
- top_k: int = 50
37
- top_p: float = 0.95
38
- speed_ratio: float = 1.0 # 0.5-2.0
39
- pitch_shift: int = 0 # -12 to +12 semitones
40
- volume_gain: float = 1.0 # 0.5-2.0
41
- silence_duration: float = 0.15 # seconds between chunks
42
-
43
- def to_dict(self):
44
- return asdict(self)
45
-
46
- @dataclass
47
- class HistoryRecord:
48
- """Bản ghi lịch sử"""
49
- id: str
50
- timestamp: str
51
- text: str
52
- full_text: str
53
- voice: str
54
- audio_path: Optional[str]
55
- duration: float
56
- status: str
57
- settings: Dict
58
- text_hash: str
59
-
60
- def to_dict(self):
61
- return asdict(self)
62
-
63
- # Thư mục lưu lịch sử
64
  try:
65
- HISTORY_DIR = "./tts_history"
 
 
 
66
  os.makedirs(HISTORY_DIR, exist_ok=True)
67
- test_file = os.path.join(HISTORY_DIR, ".test")
 
 
 
68
  with open(test_file, 'w') as f:
69
  f.write("test")
70
  os.remove(test_file)
71
  except (PermissionError, OSError):
72
- HISTORY_DIR = os.path.join(tempfile.gettempdir(), "vieneu_tts_history")
 
 
 
 
73
  os.makedirs(HISTORY_DIR, exist_ok=True)
74
- print(f"⚠️ Không có quyền ghi, sử dụng thư mục tạm: {HISTORY_DIR}")
 
75
 
76
  HISTORY_JSON = os.path.join(HISTORY_DIR, "history.json")
77
- SETTINGS_DIR = os.path.join(HISTORY_DIR, "presets")
78
- os.makedirs(SETTINGS_DIR, exist_ok=True)
79
 
80
  # Đường dẫn model
81
  BACKBONE_REPO = "pnnbao-ump/VieNeu-TTS-q8-gguf"
82
  CODEC_REPO = "neuphonic/neucodec-onnx-decoder"
83
 
84
- # Giọng mẫu
85
- VOICE_SAMPLES = {
86
  "Tuyên (nam miền Bắc)": {
87
  "audio": "./sample/Tuyên (nam miền Bắc).wav",
88
  "text": "./sample/Tuyên (nam miền Bắc).txt",
89
- "codes": "./sample/Tuyên (nam miền Bắc).pt"
 
90
  },
91
  "Vĩnh (nam miền Nam)": {
92
  "audio": "./sample/Vĩnh (nam miền Nam).wav",
93
  "text": "./sample/Vĩnh (nam miền Nam).txt",
94
- "codes": "./sample/Vĩnh (nam miền Nam).pt"
 
95
  },
96
  "Bình (nam miền Bắc)": {
97
  "audio": "./sample/Bình (nam miền Bắc).wav",
98
  "text": "./sample/Bình (nam miền Bắc).txt",
99
- "codes": "./sample/Bình (nam miền Bắc).pt"
 
100
  },
101
  "Nguyên (nam miền Nam)": {
102
  "audio": "./sample/Nguyên (nam miền Nam).wav",
103
  "text": "./sample/Nguyên (nam miền Nam).txt",
104
- "codes": "./sample/Nguyên (nam miền Nam).pt"
 
105
  },
106
  "Sơn (nam miền Nam)": {
107
  "audio": "./sample/Sơn (nam miền Nam).wav",
108
  "text": "./sample/Sơn (nam miền Nam).txt",
109
- "codes": "./sample/Sơn (nam miền Nam).pt"
 
110
  },
111
  "Đoan (nữ miền Nam)": {
112
  "audio": "./sample/Đoan (nữ miền Nam).wav",
113
  "text": "./sample/Đoan (nữ miền Nam).txt",
114
- "codes": "./sample/Đoan (nữ miền Nam).pt"
 
115
  },
116
  "Ngọc (nữ miền Bắc)": {
117
  "audio": "./sample/Ngọc (nữ miền Bắc).wav",
118
  "text": "./sample/Ngọc (nữ miền Bắc).txt",
119
- "codes": "./sample/Ngọc (nữ miền Bắc).pt"
 
120
  },
121
  "Ly (nữ miền Bắc)": {
122
  "audio": "./sample/Ly (nữ miền Bắc).wav",
123
  "text": "./sample/Ly (nữ miền Bắc).txt",
124
- "codes": "./sample/Ly (nữ miền Bắc).pt"
 
125
  },
126
  "Dung (nữ miền Nam)": {
127
  "audio": "./sample/Dung (nữ miền Nam).wav",
128
  "text": "./sample/Dung (nữ miền Nam).txt",
129
- "codes": "./sample/Dung (nữ miền Nam).pt"
 
130
  }
131
  }
132
 
133
- # --- PRESET MANAGEMENT ---
134
- DEFAULT_PRESETS = {
135
- "Mặc định": VoiceSettings(),
136
- "Giọng nhanh": VoiceSettings(speed_ratio=1.3, silence_duration=0.1),
137
- "Giọng chậm": VoiceSettings(speed_ratio=0.8, silence_duration=0.2),
138
- "Giọng trầm": VoiceSettings(pitch_shift=-3),
139
- "Giọng cao": VoiceSettings(pitch_shift=3),
140
- "Nhiệt tình": VoiceSettings(temperature=1.2, volume_gain=1.2),
141
- "Bình tĩnh": VoiceSettings(temperature=0.8, volume_gain=0.9, speed_ratio=0.9),
142
- }
143
 
144
- history_lock = threading.Lock()
145
- settings_lock = threading.Lock()
146
 
147
- def load_presets() -> Dict[str, VoiceSettings]:
148
- """Tải các preset đã lưu"""
149
- presets = DEFAULT_PRESETS.copy()
150
- try:
151
- preset_files = Path(SETTINGS_DIR).glob("*.json")
152
- for file in preset_files:
153
- with open(file, 'r', encoding='utf-8') as f:
154
- data = json.load(f)
155
- name = file.stem
156
- presets[name] = VoiceSettings(**data)
157
- except Exception as e:
158
- print(f"⚠️ Lỗi tải preset: {e}")
159
- return presets
 
 
 
 
 
 
 
160
 
161
- def save_preset(name: str, settings: VoiceSettings):
162
- """Lưu preset"""
163
- with settings_lock:
164
  try:
165
- preset_path = os.path.join(SETTINGS_DIR, f"{name}.json")
166
- with open(preset_path, 'w', encoding='utf-8') as f:
167
- json.dump(settings.to_dict(), f, indent=2)
168
- return True, f"✅ Đã lưu preset '{name}'"
 
 
 
169
  except Exception as e:
170
- return False, f" Lỗi lưu preset: {e}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
 
172
- def delete_preset(name: str):
173
- """Xóa preset"""
174
- if name in DEFAULT_PRESETS:
175
- return False, "❌ Không thể xóa preset mặc định"
176
 
177
- with settings_lock:
178
- try:
179
- preset_path = os.path.join(SETTINGS_DIR, f"{name}.json")
180
- if os.path.exists(preset_path):
181
- os.remove(preset_path)
182
- return True, f"✅ Đã xóa preset '{name}'"
183
- return False, "❌ Preset không tồn tại"
184
- except Exception as e:
185
- return False, f"❌ Lỗi xóa preset: {e}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
  # --- HISTORY MANAGEMENT ---
188
- def load_history() -> List[HistoryRecord]:
189
- """Tải lịch sử từ file JSON"""
 
 
190
  with history_lock:
191
  if os.path.exists(HISTORY_JSON):
192
  try:
193
  with open(HISTORY_JSON, 'r', encoding='utf-8') as f:
194
- data = json.load(f)
195
- return [HistoryRecord(**item) for item in data]
196
  except Exception as e:
197
  print(f"⚠️ Lỗi đọc history.json: {e}")
198
  return []
199
  return []
200
 
201
- def save_history(history: List[HistoryRecord]):
202
- """Lưu lịch sử vào file JSON"""
203
  with history_lock:
204
  try:
205
- data = [record.to_dict() for record in history]
206
  with open(HISTORY_JSON, 'w', encoding='utf-8') as f:
207
- json.dump(data, f, ensure_ascii=False, indent=2)
208
  except Exception as e:
209
  print(f"⚠️ Lỗi ghi history.json: {e}")
210
 
211
- def get_text_hash(text: str) -> str:
212
- """Tạo hash cho text để tránh trùng lặp"""
213
- return hashlib.md5(text.encode('utf-8')).hexdigest()[:8]
214
-
215
- def add_to_history(text: str, voice: str, audio_path: Optional[str],
216
- duration: float, status: str, settings: VoiceSettings) -> Optional[str]:
217
- """Thêm bản ghi vào lịch sử"""
218
  try:
219
  history = load_history()
220
 
@@ -231,27 +374,24 @@ def add_to_history(text: str, voice: str, audio_path: Optional[str],
231
  else:
232
  permanent_path = None
233
 
234
- record = HistoryRecord(
235
- id=timestamp,
236
- timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
237
- text=text[:100] + "..." if len(text) > 100 else text,
238
- full_text=text,
239
- voice=voice,
240
- audio_path=permanent_path,
241
- duration=duration,
242
- status=status,
243
- settings=settings.to_dict(),
244
- text_hash=get_text_hash(text)
245
- )
246
 
247
  history.insert(0, record)
248
 
249
- # Giới hạn 100 bản ghi
250
  if len(history) > 100:
251
  old_record = history.pop()
252
  try:
253
- if old_record.audio_path and os.path.exists(old_record.audio_path):
254
- os.remove(old_record.audio_path)
255
  except Exception as e:
256
  print(f"⚠️ Không thể xóa file cũ: {e}")
257
 
@@ -263,37 +403,22 @@ def add_to_history(text: str, voice: str, audio_path: Optional[str],
263
  traceback.print_exc()
264
  return audio_path if audio_path else None
265
 
266
- def get_history_list(filter_voice: str = "Tất cả", search_text: str = "") -> str:
267
- """Tạo HTML hiển thị lịch sử với filter"""
268
  history = load_history()
269
-
270
- # Filter
271
- if filter_voice != "Tất cả":
272
- history = [r for r in history if r.voice == filter_voice]
273
- if search_text:
274
- history = [r for r in history if search_text.lower() in r.full_text.lower()]
275
-
276
  if not history:
277
  return """
278
  <div style='padding: 20px; text-align: center; color: #64748b;'>
279
- <p style='font-size: 1.1em;'>📭 Không tìm thấy bản ghi nào</p>
 
280
  </div>
281
  """
282
 
283
  html_parts = ["<div style='font-family: system-ui; line-height: 1.6;'>"]
284
 
285
  for i, record in enumerate(history[:50], 1):
286
- status_color = "#10b981" if record.status == "Thành công" else "#ef4444"
287
- status_icon = "✅" if record.status == "Thành công" else "❌"
288
-
289
- # Settings summary
290
- settings_html = f"""
291
- <div style='font-size: 0.75em; color: #94a3b8; margin-top: 4px;'>
292
- 🎛️ Temp:{record.settings.get('temperature', 1.0):.1f} |
293
- Speed:{record.settings.get('speed_ratio', 1.0):.1f}x |
294
- Pitch:{record.settings.get('pitch_shift', 0):+d}
295
- </div>
296
- """
297
 
298
  html_parts.append(f"""
299
  <div style='
@@ -303,16 +428,13 @@ def get_history_list(filter_voice: str = "Tất cả", search_text: str = "") ->
303
  padding: 15px;
304
  margin-bottom: 12px;
305
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
306
- transition: all 0.2s;
307
- ' onmouseover="this.style.boxShadow='0 4px 6px rgba(0,0,0,0.15)'"
308
- onmouseout="this.style.boxShadow='0 1px 3px rgba(0,0,0,0.1)'">
309
  <div style='display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;'>
310
  <div style='font-weight: 600; color: #1e293b; font-size: 0.95em;'>
311
- <span style='color: #64748b;'>#{i}</span> {record.voice}
312
- {settings_html}
313
  </div>
314
  <div style='font-size: 0.85em; color: #64748b;'>
315
- {record.timestamp}
316
  </div>
317
  </div>
318
 
@@ -325,16 +447,16 @@ def get_history_list(filter_voice: str = "Tất cả", search_text: str = "") ->
325
  font-size: 0.9em;
326
  border-left: 3px solid #3b82f6;
327
  '>
328
- {record.text}
329
  </div>
330
 
331
  <div style='display: flex; gap: 20px; font-size: 0.85em; color: #64748b;'>
332
- <div>⏱️ {record.duration:.2f}s</div>
333
  <div style='color: {status_color}; font-weight: 500;'>
334
- {status_icon} {record.status}
335
  </div>
336
- <div style='margin-left: auto; color: #3b82f6; cursor: pointer;' title='{record.id}'>
337
- ID: {record.id[:13]}...
338
  </div>
339
  </div>
340
  </div>
@@ -346,65 +468,66 @@ def get_history_list(filter_voice: str = "Tất cả", search_text: str = "") ->
346
  def clear_all_history():
347
  """Xóa toàn bộ lịch sử"""
348
  history = load_history()
 
349
  for record in history:
350
  try:
351
- if record.audio_path and os.path.exists(record.audio_path):
352
- os.remove(record.audio_path)
353
  except Exception as e:
354
  print(f"⚠️ Không thể xóa file: {e}")
 
355
  save_history([])
356
  return "✅ Đã xóa toàn bộ lịch sử"
357
 
358
- # --- BACKGROUND PROCESSING ---
359
  processing_queue = Queue()
360
  is_processing = False
361
  processing_stats = {"total": 0, "success": 0, "failed": 0}
362
- stats_lock = threading.Lock()
363
-
364
- def update_stats(success: bool):
365
- """Cập nhật thống kê thread-safe"""
366
- with stats_lock:
367
- processing_stats["total"] += 1
368
- if success:
369
- processing_stats["success"] += 1
370
- else:
371
- processing_stats["failed"] += 1
372
-
373
- def get_processing_stats() -> str:
374
- """Lấy thống kê xử lý"""
375
- with stats_lock:
376
- return (f"📊 Tổng: {processing_stats['total']} | "
377
- f"✅ Thành công: {processing_stats['success']} | "
378
- f"❌ Thất bại: {processing_stats['failed']}")
379
 
380
- # --- AUDIO PROCESSING ---
381
- def apply_audio_effects(audio: np.ndarray, settings: VoiceSettings) -> np.ndarray:
382
- """Áp dụng hiệu ứng audio"""
383
- try:
384
- # Speed change
385
- if settings.speed_ratio != 1.0:
386
- audio = librosa.effects.time_stretch(audio, rate=settings.speed_ratio)
 
387
 
388
- # Pitch shift
389
- if settings.pitch_shift != 0:
390
- audio = librosa.effects.pitch_shift(
391
- audio,
392
- sr=SAMPLE_RATE,
393
- n_steps=settings.pitch_shift
394
- )
395
 
396
- # Volume
397
- if settings.volume_gain != 1.0:
398
- audio = audio * settings.volume_gain
399
- audio = np.clip(audio, -1.0, 1.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
400
 
401
- return audio
402
- except Exception as e:
403
- print(f"⚠️ Lỗi áp dụng hiệu ứng: {e}")
404
- return audio
 
 
 
 
 
405
 
406
- def split_text_into_chunks(text: str, max_chars: int = 256) -> List[str]:
407
- """Chia text thành chunks thông minh"""
408
  sentences = re.split(r'([.!?,;])', text)
409
  chunks = []
410
  current = ""
@@ -426,7 +549,7 @@ def split_text_into_chunks(text: str, max_chars: int = 256) -> List[str]:
426
 
427
  return chunks if chunks else [text]
428
 
429
- def decode_audio(codes_str: str, codec) -> np.ndarray:
430
  """Decode speech tokens to audio"""
431
  speech_ids = [int(num) for num in re.findall(r"<\|speech_(\d+)\|>", codes_str)]
432
 
@@ -465,18 +588,25 @@ except Exception as e:
465
  print(f"❌ Lỗi khi tải model: {e}")
466
  model_loaded = False
467
 
468
- # --- SYNTHESIS FUNCTIONS ---
469
- def synthesize_speech_internal(text: str, voice_choice: str,
470
- settings: VoiceSettings) -> Optional[str]:
471
- """Internal synthesis function"""
472
  global backbone, codec, model_loaded
473
 
474
- if not model_loaded or not text.strip() or voice_choice not in VOICE_SAMPLES:
 
 
 
 
 
 
 
 
 
475
  return None
476
 
477
  raw_text = text.strip()
478
 
479
- # Load reference
480
  ref_text_path = VOICE_SAMPLES[voice_choice]["text"]
481
  try:
482
  with open(ref_text_path, "r", encoding="utf-8") as f:
@@ -485,8 +615,9 @@ def synthesize_speech_internal(text: str, voice_choice: str,
485
  print(f"❌ Lỗi đọc file text mẫu: {e}")
486
  return None
487
 
488
- # Load codes
489
  codes_path = VOICE_SAMPLES[voice_choice]["codes"]
 
490
  try:
491
  ref_codes_tensor = torch.load(codes_path, map_location="cpu")
492
  if isinstance(ref_codes_tensor, torch.Tensor):
@@ -498,12 +629,13 @@ def synthesize_speech_internal(text: str, voice_choice: str,
498
  return None
499
 
500
  if ref_codes is None or len(ref_codes) == 0:
 
501
  return None
502
 
503
- # Split text
504
  text_chunks = split_text_into_chunks(raw_text, max_chars=MAX_CHARS_PER_CHUNK)
 
505
  all_audio_segments = []
506
- silence_pad = np.zeros(int(SAMPLE_RATE * settings.silence_duration), dtype=np.float32)
507
 
508
  start_time = time.time()
509
 
@@ -511,41 +643,35 @@ def synthesize_speech_internal(text: str, voice_choice: str,
511
  for i, chunk in enumerate(text_chunks):
512
  print(f"[Internal] Xử lý chunk {i+1}/{len(text_chunks)}")
513
 
514
- # Phonemize
515
  ref_text_phoneme = phonemize_with_dict(ref_text_raw)
516
  input_text_phoneme = phonemize_with_dict(chunk)
517
 
518
- # Create prompt
519
  codes_str = "".join([f"<|speech_{idx}|>" for idx in ref_codes])
 
520
  prompt = (
521
  f"user: Convert the text to speech:<|TEXT_PROMPT_START|>{ref_text_phoneme} {input_text_phoneme}"
522
  f"<|TEXT_PROMPT_END|>\nassistant:<|SPEECH_GENERATION_START|>{codes_str}"
523
  )
524
 
525
- # Generate
526
  output = backbone(
527
  prompt,
528
  max_tokens=2048,
529
- temperature=settings.temperature,
530
- top_k=settings.top_k,
531
- top_p=settings.top_p,
532
  stop=["<|SPEECH_GENERATION_END|>"],
533
  )
534
  output_str = output["choices"][0]["text"]
535
 
536
- # Decode
537
  chunk_wav = decode_audio(output_str, codec)
538
 
539
- # Apply effects
540
- chunk_wav = apply_audio_effects(chunk_wav, settings)
541
-
542
  if chunk_wav is not None and len(chunk_wav) > 0:
543
  all_audio_segments.append(chunk_wav)
544
  if i < len(text_chunks) - 1:
545
  all_audio_segments.append(silence_pad)
546
 
547
  if not all_audio_segments:
548
- add_to_history(raw_text, voice_choice, None, 0, "Lỗi: Không sinh được audio", settings)
 
549
  return None
550
 
551
  final_wav = np.concatenate(all_audio_segments)
@@ -554,23 +680,20 @@ def synthesize_speech_internal(text: str, voice_choice: str,
554
  output_path = tmp.name
555
 
556
  process_time = time.time() - start_time
557
- permanent_path = add_to_history(raw_text, voice_choice, output_path, process_time, "Thành công", settings)
558
 
559
- update_stats(True)
 
560
  print(f"✅ Hoàn thành: {permanent_path}")
561
  return permanent_path
562
 
563
  except Exception as e:
564
  import traceback
565
  traceback.print_exc()
566
- add_to_history(raw_text, voice_choice, None, 0, f"Lỗi: {str(e)}", settings)
567
- update_stats(False)
568
  return None
569
 
570
- def synthesize_speech(text: str, voice_choice: str,
571
- temperature: float, top_k: int, top_p: float,
572
- speed_ratio: float, pitch_shift: int, volume_gain: float,
573
- silence_duration: float):
574
  """Main synthesis function với UI feedback"""
575
  global backbone, codec, model_loaded
576
 
@@ -586,20 +709,8 @@ def synthesize_speech(text: str, voice_choice: str,
586
  yield None, "⚠️ Vui lòng chọn giọng mẫu."
587
  return
588
 
589
- # Create settings
590
- settings = VoiceSettings(
591
- temperature=temperature,
592
- top_k=top_k,
593
- top_p=top_p,
594
- speed_ratio=speed_ratio,
595
- pitch_shift=pitch_shift,
596
- volume_gain=volume_gain,
597
- silence_duration=silence_duration
598
- )
599
-
600
  raw_text = text.strip()
601
 
602
- # Load reference
603
  ref_text_path = VOICE_SAMPLES[voice_choice]["text"]
604
  try:
605
  with open(ref_text_path, "r", encoding="utf-8") as f:
@@ -610,8 +721,9 @@ def synthesize_speech(text: str, voice_choice: str,
610
 
611
  yield None, "📄 Đang xử lý Reference..."
612
 
613
- # Load codes
614
  codes_path = VOICE_SAMPLES[voice_choice]["codes"]
 
615
  try:
616
  ref_codes_tensor = torch.load(codes_path, map_location="cpu")
617
  if isinstance(ref_codes_tensor, torch.Tensor):
@@ -626,14 +738,13 @@ def synthesize_speech(text: str, voice_choice: str,
626
  yield None, "❌ Codes tham chiếu không hợp lệ."
627
  return
628
 
629
- # Split text
630
  text_chunks = split_text_into_chunks(raw_text, max_chars=MAX_CHARS_PER_CHUNK)
631
  total_chunks = len(text_chunks)
632
 
633
  yield None, f"🚀 Bắt đầu tổng hợp ({total_chunks} đoạn)..."
634
 
635
  all_audio_segments = []
636
- silence_pad = np.zeros(int(SAMPLE_RATE * settings.silence_duration), dtype=np.float32)
637
 
638
  start_time = time.time()
639
 
@@ -641,35 +752,27 @@ def synthesize_speech(text: str, voice_choice: str,
641
  for i, chunk in enumerate(text_chunks):
642
  yield None, f"⏳ Đang xử lý đoạn {i+1}/{total_chunks}... ({int((i/total_chunks)*100)}%)"
643
 
644
- # Phonemize
645
  ref_text_phoneme = phonemize_with_dict(ref_text_raw)
646
  input_text_phoneme = phonemize_with_dict(chunk)
647
 
648
- # Create prompt
649
  codes_str = "".join([f"<|speech_{idx}|>" for idx in ref_codes])
 
650
  prompt = (
651
  f"user: Convert the text to speech:<|TEXT_PROMPT_START|>{ref_text_phoneme} {input_text_phoneme}"
652
  f"<|TEXT_PROMPT_END|>\nassistant:<|SPEECH_GENERATION_START|>{codes_str}"
653
  )
654
 
655
- # Generate
656
  output = backbone(
657
  prompt,
658
  max_tokens=2048,
659
- temperature=settings.temperature,
660
- top_k=settings.top_k,
661
- top_p=settings.top_p,
662
  stop=["<|SPEECH_GENERATION_END|>"],
663
  )
664
  output_str = output["choices"][0]["text"]
665
 
666
- # Decode
667
  chunk_wav = decode_audio(output_str, codec)
668
 
669
- # Apply effects
670
- yield None, f"🎨 Áp dụng hiệu ứng cho đoạn {i+1}/{total_chunks}..."
671
- chunk_wav = apply_audio_effects(chunk_wav, settings)
672
-
673
  if chunk_wav is not None and len(chunk_wav) > 0:
674
  all_audio_segments.append(chunk_wav)
675
  if i < total_chunks - 1:
@@ -677,8 +780,7 @@ def synthesize_speech(text: str, voice_choice: str,
677
 
678
  if not all_audio_segments:
679
  yield None, "❌ Không sinh được audio nào."
680
- add_to_history(raw_text, voice_choice, None, 0, "Lỗi: Không sinh được audio", settings)
681
- update_stats(False)
682
  return
683
 
684
  yield None, "💾 Đang ghép file và lưu..."
@@ -689,110 +791,53 @@ def synthesize_speech(text: str, voice_choice: str,
689
  output_path = tmp.name
690
 
691
  process_time = time.time() - start_time
692
- audio_duration = len(final_wav) / SAMPLE_RATE
693
- rtf = process_time / audio_duration
694
 
695
- # Lưu vào lịch sử
696
- permanent_path = add_to_history(raw_text, voice_choice, output_path, process_time, "Thành công", settings)
697
- update_stats(True)
698
 
699
- yield permanent_path, (f"✅ Hoàn tất! | Thời gian: {process_time:.2f}s | "
700
- f"RTF: {rtf:.3f} | Audio: {audio_duration:.2f}s")
701
 
702
  except Exception as e:
703
  import traceback
704
  traceback.print_exc()
705
- add_to_history(raw_text, voice_choice, None, 0, f"Lỗi: {str(e)}", settings)
706
- update_stats(False)
707
  yield None, f"❌ Lỗi tổng hợp: {str(e)}"
708
 
709
- def load_history_item(item_index: str):
710
- """Tải một item từ lịch sử"""
 
 
 
 
711
  if not item_index or item_index.strip() == "":
712
- return None, "", "", "", "⚠️ Vui lòng nhập số thứ tự"
713
 
714
  try:
715
  index = int(item_index.strip()) - 1
716
  history = load_history()
717
 
718
  if index < 0 or index >= len(history):
719
- return None, "", "", "", f"❌ Số thứ tự không hợp lệ (1-{len(history)})"
720
 
721
  record = history[index]
722
 
723
  audio_path = None
724
- if record.audio_path and os.path.exists(record.audio_path):
725
- audio_path = record.audio_path
726
 
727
  info = f"""
728
- 📅 Thời gian: {record.timestamp}
729
- ⏱️ Thời lượng: {record.duration:.2f}s
730
- 🎭 Giọng: {record.voice}
731
- 📊 Trạng thái: {record.status}
732
- 🆔 ID: {record.id}
733
-
734
- 🎛️ Cài đặt đã dùng:
735
- • Temperature: {record.settings.get('temperature', 1.0):.2f}
736
- • Top-K: {record.settings.get('top_k', 50)}
737
- • Top-P: {record.settings.get('top_p', 0.95):.2f}
738
- • Tốc độ: {record.settings.get('speed_ratio', 1.0):.1f}x
739
- • Cao độ: {record.settings.get('pitch_shift', 0):+d} semitones
740
- • Âm lượng: {record.settings.get('volume_gain', 1.0):.1f}x
741
- • Khoảng lặng: {record.settings.get('silence_duration', 0.15):.2f}s
742
  """.strip()
743
 
744
- return audio_path, record.full_text, record.voice, record.id, info
745
 
746
  except ValueError:
747
- return None, "", "", "", "❌ Vui lòng nhập số hợp lệ"
748
  except Exception as e:
749
- return None, "", "", "", f"❌ Lỗi: {str(e)}"
750
-
751
- def load_preset_to_ui(preset_name: str):
752
- """Tải preset vào UI"""
753
- presets = load_presets()
754
- if preset_name not in presets:
755
- return [1.0, 50, 0.95, 1.0, 0, 1.0, 0.15, f"❌ Preset '{preset_name}' không tồn tại"]
756
-
757
- settings = presets[preset_name]
758
- return [
759
- settings.temperature,
760
- settings.top_k,
761
- settings.top_p,
762
- settings.speed_ratio,
763
- settings.pitch_shift,
764
- settings.volume_gain,
765
- settings.silence_duration,
766
- f"✅ Đã tải preset '{preset_name}'"
767
- ]
768
-
769
- def save_current_preset(name: str, temp: float, top_k: int, top_p: float,
770
- speed: float, pitch: int, volume: float, silence: float):
771
- """Lưu cài đặt hiện tại thành preset"""
772
- if not name or name.strip() == "":
773
- return "❌ Vui lòng nhập tên preset"
774
-
775
- name = name.strip()
776
- if name in DEFAULT_PRESETS:
777
- return f"❌ Không thể ghi đè preset mặc định '{name}'"
778
-
779
- settings = VoiceSettings(
780
- temperature=temp,
781
- top_k=top_k,
782
- top_p=top_p,
783
- speed_ratio=speed,
784
- pitch_shift=pitch,
785
- volume_gain=volume,
786
- silence_duration=silence
787
- )
788
-
789
- success, msg = save_preset(name, settings)
790
- return msg
791
-
792
- def get_preset_list():
793
- """Lấy danh sách preset"""
794
- presets = load_presets()
795
- return list(presets.keys())
796
 
797
  # --- UI SETUP ---
798
  theme = gr.themes.Ocean(
@@ -806,7 +851,7 @@ theme = gr.themes.Ocean(
806
  )
807
 
808
  css = """
809
- .container { max-width: 1600px; margin: auto; }
810
  .header-box {
811
  text-align: center;
812
  margin-bottom: 25px;
@@ -814,18 +859,15 @@ css = """
814
  background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
815
  border-radius: 12px;
816
  color: white;
817
- box-shadow: 0 10px 40px rgba(0,0,0,0.2);
818
  }
819
  .header-title {
820
- font-size: 2.8rem;
821
  font-weight: 800;
822
- margin-bottom: 10px;
823
  }
824
  .gradient-text {
825
- background: linear-gradient(45deg, #60A5FA, #22D3EE, #A78BFA);
826
  -webkit-background-clip: text;
827
  -webkit-text-fill-color: transparent;
828
- background-clip: text;
829
  }
830
  .status-box {
831
  font-weight: bold;
@@ -833,11 +875,31 @@ css = """
833
  border: none;
834
  background: transparent;
835
  }
836
- .settings-panel {
837
- background: #f8fafc;
838
- padding: 20px;
839
- border-radius: 10px;
840
- border: 2px solid #e2e8f0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
841
  }
842
  .history-container {
843
  max-height: 650px;
@@ -848,22 +910,10 @@ css = """
848
  }
849
  .info-box {
850
  background: #f8fafc;
851
- padding: 15px;
852
- border-radius: 8px;
853
  border-left: 4px solid #3b82f6;
854
  margin: 10px 0;
855
- font-family: 'Courier New', monospace;
856
- font-size: 0.9em;
857
- }
858
- .preset-badge {
859
- display: inline-block;
860
- background: #3b82f6;
861
- color: white;
862
- padding: 4px 12px;
863
- border-radius: 12px;
864
- font-size: 0.85em;
865
- font-weight: 600;
866
- margin: 2px;
867
  }
868
  """
869
 
@@ -873,29 +923,27 @@ EXAMPLES_LIST = [
873
  ["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)"],
874
  ]
875
 
876
- initial_status = (f"✅ Model đã tải thành công! (Chạy trên **{DEVICE_INFO}**). "
877
- f"Full features enabled: Custom Voice Settings ✅ | Presets ✅ | History ✅") if model_loaded else "❌ Lỗi khi tải model."
878
 
879
- with gr.Blocks(title=f"VieNeu-TTS v{VERSION}", theme=theme, css=css) as demo:
880
  with gr.Column(elem_classes="container"):
881
  gr.HTML(f"""
882
  <div class="header-box">
883
  <h1 class="header-title">
884
- <span style="font-size: 2.5rem;">🦜</span>
885
- <span class="gradient-text">VieNeu-TTS Studio Pro</span>
886
  </h1>
887
- <p style="margin: 10px 0; font-size: 1.1em;">
888
- <strong>v{VERSION}</strong> | {DEVICE_INFO} | Advanced Voice Customization
889
- </p>
890
- <p style="font-size: 0.85rem; color: #94a3b8;">
891
- 📁 History: {HISTORY_DIR} | 🎛️ Presets: {SETTINGS_DIR}
892
- </p>
893
- <div style="margin-top: 15px; display: flex; justify-content: center; gap: 10px; flex-wrap: wrap;">
894
- <span class="preset-badge">🎯 Custom Settings</span>
895
- <span class="preset-badge">💾 Presets Manager</span>
896
- <span class="preset-badge">🎨 Audio Effects</span>
897
- <span class="preset-badge">📊 Advanced Stats</span>
898
- <span class="preset-badge">🔍 Smart Search</span>
899
  </div>
900
  </div>
901
  """)
@@ -904,109 +952,28 @@ with gr.Blocks(title=f"VieNeu-TTS v{VERSION}", theme=theme, css=css) as demo:
904
 
905
  # --- TABS ---
906
  with gr.Tabs():
907
- # TAB 1: Tổng hợp với Custom Settings
908
- with gr.Tab("🎙️ Tổng hợp nâng cao"):
909
- with gr.Row():
910
- with gr.Column(scale=2):
911
  text_input = gr.Textbox(
912
- label=f"📝 Văn bản (Chunk: {MAX_CHARS_PER_CHUNK} ký tự)",
913
- lines=6,
914
  placeholder="Nhập văn bản tiếng Việt cần tổng hợp...",
915
- 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.",
916
  )
917
 
918
  voice_select = gr.Dropdown(
919
- choices=list(VOICE_SAMPLES.keys()),
920
- value=list(VOICE_SAMPLES.keys())[0],
921
  label="👤 Chọn giọng mẫu",
922
  )
923
 
924
- # Preset Manager
925
- with gr.Accordion("🎛️ Preset Manager", open=False):
926
- with gr.Row():
927
- preset_dropdown = gr.Dropdown(
928
- choices=get_preset_list(),
929
- value="Mặc định",
930
- label="Chọn preset",
931
- scale=2
932
- )
933
- btn_load_preset = gr.Button("📂 Tải", size="sm", scale=1)
934
- btn_delete_preset = gr.Button("🗑️ Xóa", size="sm", variant="stop", scale=1)
935
-
936
- with gr.Row():
937
- preset_name_input = gr.Textbox(
938
- label="Tên preset mới",
939
- placeholder="Nhập tên preset...",
940
- scale=3
941
- )
942
- btn_save_preset = gr.Button("💾 Lưu preset", variant="primary", scale=1)
943
-
944
- preset_status = gr.Textbox(
945
- label="Trạng thái preset",
946
- interactive=False,
947
- show_label=False
948
- )
949
-
950
- # Custom Voice Settings
951
- with gr.Accordion("⚙️ Cài đặt giọng nói tùy chỉnh", open=True, elem_classes="settings-panel"):
952
- gr.Markdown("### 🎯 Model Parameters")
953
- with gr.Row():
954
- temperature_slider = gr.Slider(
955
- minimum=0.1, maximum=2.0, value=1.0, step=0.1,
956
- label="🌡️ Temperature (Độ sáng tạo)",
957
- info="Cao = đa dạng, Thấp = ổn định"
958
- )
959
- top_k_slider = gr.Slider(
960
- minimum=1, maximum=100, value=50, step=1,
961
- label="🔝 Top-K",
962
- info="Số lượng token được xem xét"
963
- )
964
-
965
- top_p_slider = gr.Slider(
966
- minimum=0.1, maximum=1.0, value=0.95, step=0.05,
967
- label="🎲 Top-P (Nucleus Sampling)",
968
- info="Xác suất tích lũy"
969
- )
970
-
971
- gr.Markdown("### 🎨 Audio Effects")
972
- with gr.Row():
973
- speed_slider = gr.Slider(
974
- minimum=0.5, maximum=2.0, value=1.0, step=0.1,
975
- label="⚡ Tốc độ (Speed)",
976
- info="0.5x = chậm, 2.0x = nhanh"
977
- )
978
- pitch_slider = gr.Slider(
979
- minimum=-12, maximum=12, value=0, step=1,
980
- label="🎵 Cao độ (Pitch Shift)",
981
- info="Semitones: -12 (thấp) đến +12 (cao)"
982
- )
983
-
984
- with gr.Row():
985
- volume_slider = gr.Slider(
986
- minimum=0.5, maximum=2.0, value=1.0, step=0.1,
987
- label="🔊 Âm lượng (Volume)",
988
- info="0.5x = nhỏ, 2.0x = to"
989
- )
990
- silence_slider = gr.Slider(
991
- minimum=0.05, maximum=1.0, value=0.15, step=0.05,
992
- label="⏸️ Khoảng lặng (Pause)",
993
- info="Giây giữa các chunk"
994
- )
995
-
996
- with gr.Row():
997
- btn_reset_settings = gr.Button("🔄 Reset về mặc định", size="sm")
998
-
999
  with gr.Row():
1000
- btn_generate = gr.Button(
1001
- "🎵 Bắt đầu tổng hợp",
1002
- variant="primary",
1003
- size="lg",
1004
- interactive=model_loaded,
1005
- scale=3
1006
- )
1007
- btn_clear = gr.Button("🗑️ Xóa", variant="secondary", size="lg", scale=1)
1008
 
1009
- with gr.Column(scale=1):
1010
  audio_output = gr.Audio(
1011
  label="🔊 Kết quả",
1012
  type="filepath",
@@ -1016,43 +983,110 @@ with gr.Blocks(title=f"VieNeu-TTS v{VERSION}", theme=theme, css=css) as demo:
1016
  label="📊 Trạng thái",
1017
  elem_classes="status-box",
1018
  value="Chờ nhập văn bản...",
1019
- lines=3
1020
  )
1021
-
1022
- gr.Markdown("### 💡 Quick Tips")
1023
- gr.Markdown("""
1024
- - **Temperature ↑** → Giọng đa dạng hơn
1025
- - **Speed < 1.0** → Rõ ràng, dễ nghe
1026
- - **Pitch +3** → Giọng trẻ/nữ tính
1027
- - **Pitch -3** → Giọng trầm/nam tính
1028
- - **Volume 1.2** → Tăng âm lượng 20%
1029
- """)
1030
 
1031
  gr.Examples(
1032
  examples=EXAMPLES_LIST,
1033
  inputs=[text_input, voice_select],
 
 
 
1034
  label="💡 Các ví dụ nhanh"
1035
  )
1036
 
1037
- # TAB 2: Lịch sử nâng cao
1038
- with gr.Tab("📜 Lịch sử & Phân tích"):
 
 
1039
  with gr.Row():
1040
- with gr.Column(scale=3):
1041
- gr.Markdown("### 📋 Danh sách lịch sử")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1042
 
1043
  with gr.Row():
1044
- filter_voice = gr.Dropdown(
1045
- choices=["Tất cả"] + list(VOICE_SAMPLES.keys()),
1046
- value="Tất cả",
1047
- label="Lọc theo giọng",
1048
- scale=2
1049
- )
1050
- search_text = gr.Textbox(
1051
- label="Tìm kiếm văn bản",
1052
- placeholder="Nhập từ khóa...",
1053
- scale=3
1054
- )
1055
- btn_search = gr.Button("🔍 Tìm", size="sm", variant="primary", scale=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1056
 
1057
  with gr.Row():
1058
  btn_refresh = gr.Button("🔄 Làm mới", size="sm", variant="secondary")
@@ -1075,21 +1109,15 @@ with gr.Blocks(title=f"VieNeu-TTS v{VERSION}", theme=theme, css=css) as demo:
1075
 
1076
  with gr.Row():
1077
  history_select = gr.Textbox(
1078
- label="Nhập số thứ tự",
1079
- placeholder="Số...",
1080
  scale=3
1081
  )
1082
  btn_load_history = gr.Button("📂 Tải", variant="primary", scale=1)
1083
 
1084
- history_id = gr.Textbox(
1085
- label="🆔 ID bản ghi",
1086
- interactive=False,
1087
- visible=False
1088
- )
1089
-
1090
  history_info = gr.Textbox(
1091
- label="ℹ️ Thông tin chi tiết",
1092
- lines=10,
1093
  elem_classes="info-box"
1094
  )
1095
 
@@ -1110,172 +1138,77 @@ with gr.Blocks(title=f"VieNeu-TTS v{VERSION}", theme=theme, css=css) as demo:
1110
  )
1111
 
1112
  with gr.Row():
1113
- btn_reuse = gr.Button("♻️ Tái sử dụng", variant="secondary", scale=1)
1114
- btn_export = gr.Button("📥 Export", variant="primary", scale=1)
1115
 
1116
- # TAB 3: Thông tin & Hướng dẫn
1117
- with gr.Tab("ℹ️ Thông tin & Hướng dẫn"):
1118
- with gr.Row():
1119
- with gr.Column():
1120
- gr.Markdown(f"""
1121
- ## 🎯 Về VieNeu-TTS Studio Pro
1122
-
1123
- **VieNeu-TTS** 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
1124
- với khả năng tùy chỉnh giọng nói toàn diện.
1125
-
1126
- ### ⚙️ Cấu hình hiện tại
1127
- ```
1128
- Version: {VERSION}
1129
- Model Backbone: Q4 GGUF (llama-cpp)
1130
- Codec: ONNX Decoder
1131
- Sample Rate: {SAMPLE_RATE} Hz
1132
- Max Chunk Size: {MAX_CHARS_PER_CHUNK} tự
1133
- History Dir: {HISTORY_DIR}
1134
- Presets Dir: {SETTINGS_DIR}
1135
- ```
1136
-
1137
- ### 🎭 Giọng mẫu ({len(VOICE_SAMPLES)} giọng)
1138
-
1139
- | Khu vực | Nam | Nữ |
1140
- |---------|-----|-----|
1141
- | **Miền Bắc** | Tuyên, Bình | Ngọc, Ly |
1142
- | **Miền Nam** | Vĩnh, Nguyên, Sơn | Đoan, Dung |
1143
-
1144
- ### 🎛️ Tính năng nâng cao
1145
-
1146
- #### Model Parameters
1147
- - **Temperature (0.1-2.0)**: Kiểm soát độ sáng tạo
1148
- - `0.5-0.8`: Ổn định, nhất quán
1149
- - `1.0`: Cân bằng (mặc định)
1150
- - `1.2-2.0`: Đa dạng, biểu cảm
1151
-
1152
- - **Top-K (1-100)**: Giới hạn lựa chọn token
1153
- - `20-30`: Bảo thủ
1154
- - `50`: Cân bằng (mặc định)
1155
- - `80-100`: Sáng tạo
1156
-
1157
- - **Top-P (0.1-1.0)**: Nucleus sampling
1158
- - `0.9`: An toàn
1159
- - `0.95`: Cân bằng (mặc định)
1160
- - `1.0`: Tự do hoàn toàn
1161
-
1162
- #### Audio Effects
1163
- - **Speed (0.5-2.0x)**: Thay đổi tốc độ không ảnh hưởng cao độ
1164
- - **Pitch (-12 đến +12 semitones)**: Thay đổi cao độ giọng nói
1165
- - **Volume (0.5-2.0x)**: Điều chỉnh âm lượng
1166
- - **Silence (0.05-1.0s)**: Khoảng dừng giữa các câu
1167
-
1168
- ### 📚 Preset System
1169
-
1170
- Hệ thống preset giúp lưu và tái sử dụng cài đặt yêu thích:
1171
-
1172
- **Preset mặc định:**
1173
- - 🎯 **Mặc định**: Cài đặt chuẩn
1174
- - **Giọng nhanh**: Speed 1.3x, pause ngắn
1175
- - 🐢 **Giọng chậm**: Speed 0.8x, pause dài
1176
- - 🎵 **Giọng trầm**: Pitch -3
1177
- - 🎶 **Giọng cao**: Pitch +3
1178
- - 🔥 **Nhiệt tình**: Temp 1.2, volume cao
1179
- - 😌 **Bình tĩnh**: Temp 0.8, speed chậm
1180
-
1181
- **Tạo preset mới:**
1182
- 1. Điều chỉnh các thông số theo ý muốn
1183
- 2. Nhập tên preset
1184
- 3. Nhấn "Lưu preset"
1185
-
1186
- ### 📊 History & Analytics
1187
-
1188
- - ✅ Lưu tự động mọi lần tổng hợp
1189
- - 🔍 Tìm kiếm và lọc theo giọng
1190
- - 📈 Thống kê chi tiết (thời gian, RTF, settings)
1191
- - ♻️ Tái sử dụng cài đặt từ lịch sử
1192
- - 🗑️ Tự động xóa khi > 100 bản ghi
1193
-
1194
- ### 🚀 Workflow gợi ý
1195
-
1196
- 1. **Thử nghiệm nhanh**:
1197
- - Chọn giọng → Nhập text → Tổng hợp
1198
- - Dùng preset mặc định
1199
-
1200
- 2. **Tùy chỉnh chi tiết**:
1201
- - Thử các preset có sẵn
1202
- - Điều chỉnh từng thông số
1203
- - Lưu thành preset mới
1204
-
1205
- 3. **Production**:
1206
- - Dùng preset đã tối ưu
1207
- - Kiểm tra lịch sử để đảm bảo chất lượng
1208
- - Export audio khi hài lòng
1209
-
1210
- ### 🎓 Tips & Tricks
1211
-
1212
- **Giọng nói tự nhiên**:
1213
- ```
1214
- Temperature: 0.9-1.1
1215
- Speed: 1.0
1216
- Pitch: 0
1217
- ```
1218
-
1219
- **Podcast/Audiobook**:
1220
- ```
1221
- Temperature: 0.8
1222
- Speed: 0.9
1223
- Silence: 0.2s
1224
- Volume: 1.1
1225
- ```
1226
-
1227
- **Quảng cáo/Promotional**:
1228
- ```
1229
- Temperature: 1.2
1230
- Speed: 1.1
1231
- Volume: 1.3
1232
- Pitch: +2
1233
- ```
1234
-
1235
- **Tin tức/News**:
1236
- ```
1237
- Temperature: 0.85
1238
- Speed: 1.0
1239
- Silence: 0.15s
1240
- ```
1241
-
1242
- ### 🔧 Troubleshooting
1243
-
1244
- **Giọng không tự nhiên?**
1245
- - Giảm Temperature xuống 0.8-0.9
1246
- - Kiểm tra Speed (nên = 1.0)
1247
-
1248
- **Âm thanh vỡ/méo?**
1249
- - Giảm Volume về 1.0
1250
- - Kiểm tra Pitch (tránh quá ±6)
1251
-
1252
- **Xử lý chậm?**
1253
- - Chia nhỏ văn bản
1254
- - Đóng các tab khác
1255
-
1256
- ### 📊 Thống kê hệ thống
1257
- {get_processing_stats()}
1258
-
1259
- ### 🔗 Liên kết
1260
- - 🌐 [GitHub Repository](https://github.com/pnnbao97/VieNeu-TTS)
1261
- - 🤗 [Model Hub](https://huggingface.co/pnnbao-ump)
1262
- - 📖 [Documentation](https://github.com/pnnbao97/VieNeu-TTS/wiki)
1263
-
1264
- ---
1265
- **Phiên bản**: {VERSION} | **Tác giả**: Phạm Nguyễn Ngọc Bảo
1266
- **License**: MIT | **Last Updated**: December 2024
1267
- """)
1268
 
1269
- # ==================== EVENT HANDLERS ====================
1270
-
1271
- # Tab 1: Tổng hợp
1272
  btn_generate.click(
1273
  fn=synthesize_speech,
1274
- inputs=[
1275
- text_input, voice_select,
1276
- temperature_slider, top_k_slider, top_p_slider,
1277
- speed_slider, pitch_slider, volume_slider, silence_slider
1278
- ],
1279
  outputs=[audio_output, status_output]
1280
  )
1281
 
@@ -1284,65 +1217,99 @@ with gr.Blocks(title=f"VieNeu-TTS v{VERSION}", theme=theme, css=css) as demo:
1284
  outputs=[text_input, audio_output, status_output]
1285
  )
1286
 
1287
- # Reset settings
1288
- def reset_settings():
1289
- return [1.0, 50, 0.95, 1.0, 0, 1.0, 0.15, "✅ Đã reset về mặc định"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1290
 
1291
- btn_reset_settings.click(
1292
- fn=reset_settings,
 
1293
  outputs=[
1294
- temperature_slider, top_k_slider, top_p_slider,
1295
- speed_slider, pitch_slider, volume_slider, silence_slider,
1296
- preset_status
 
 
 
 
1297
  ]
1298
  )
1299
 
1300
- # Preset management
1301
- btn_load_preset.click(
1302
- fn=load_preset_to_ui,
1303
- inputs=preset_dropdown,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1304
  outputs=[
1305
- temperature_slider, top_k_slider, top_p_slider,
1306
- speed_slider, pitch_slider, volume_slider, silence_slider,
1307
- preset_status
 
1308
  ]
1309
  )
1310
 
1311
- btn_save_preset.click(
1312
- fn=save_current_preset,
1313
- inputs=[
1314
- preset_name_input,
1315
- temperature_slider, top_k_slider, top_p_slider,
1316
- speed_slider, pitch_slider, volume_slider, silence_slider
1317
- ],
1318
- outputs=preset_status
1319
- ).then(
1320
- fn=lambda: (get_preset_list(), ""),
1321
- outputs=[preset_dropdown, preset_name_input]
1322
- )
1323
-
1324
- def delete_preset_handler(name):
1325
- success, msg = delete_preset(name)
1326
- return get_preset_list(), msg
1327
-
1328
- btn_delete_preset.click(
1329
- fn=delete_preset_handler,
1330
- inputs=preset_dropdown,
1331
- outputs=[preset_dropdown, preset_status]
1332
- )
1333
-
1334
- # Tab 2: Lịch sử
1335
- def search_history(voice_filter, text_search):
1336
- return get_history_list(voice_filter, text_search)
1337
 
1338
- btn_search.click(
1339
- fn=search_history,
1340
- inputs=[filter_voice, search_text],
1341
- outputs=history_display
1342
  )
1343
 
 
1344
  btn_refresh.click(
1345
- fn=lambda: get_history_list(),
1346
  outputs=history_display
1347
  ).then(
1348
  fn=get_processing_stats,
@@ -1352,7 +1319,7 @@ with gr.Blocks(title=f"VieNeu-TTS v{VERSION}", theme=theme, css=css) as demo:
1352
  btn_load_history.click(
1353
  fn=load_history_item,
1354
  inputs=history_select,
1355
- outputs=[history_audio, history_text, history_voice, history_id, history_info]
1356
  )
1357
 
1358
  def clear_all():
@@ -1364,145 +1331,27 @@ with gr.Blocks(title=f"VieNeu-TTS v{VERSION}", theme=theme, css=css) as demo:
1364
  outputs=[history_display, stats_display]
1365
  )
1366
 
1367
- # Tái sử dụng văn bản và settings
1368
- def reuse_from_history(text, voice, record_id):
1369
- if not record_id:
1370
- return [text, voice, 1.0, 50, 0.95, 1.0, 0, 1.0, 0.15,
1371
- "⚠️ Không có bản ghi để tải cài đặt"]
1372
-
1373
- history = load_history()
1374
- record = next((r for r in history if r.id == record_id), None)
1375
-
1376
- if not record:
1377
- return [text, voice, 1.0, 50, 0.95, 1.0, 0, 1.0, 0.15,
1378
- "❌ Không tìm thấy bản ghi"]
1379
-
1380
- settings = record.settings
1381
- return [
1382
- text,
1383
- voice,
1384
- settings.get('temperature', 1.0),
1385
- settings.get('top_k', 50),
1386
- settings.get('top_p', 0.95),
1387
- settings.get('speed_ratio', 1.0),
1388
- settings.get('pitch_shift', 0),
1389
- settings.get('volume_gain', 1.0),
1390
- settings.get('silence_duration', 0.15),
1391
- f"✅ Đã tải văn bản và cài đặt từ bản ghi #{record_id[:8]}"
1392
- ]
1393
 
1394
  btn_reuse.click(
1395
- fn=reuse_from_history,
1396
- inputs=[history_text, history_voice, history_id],
1397
- outputs=[
1398
- text_input, voice_select,
1399
- temperature_slider, top_k_slider, top_p_slider,
1400
- speed_slider, pitch_slider, volume_slider, silence_slider,
1401
- status_output
1402
- ]
1403
- )
1404
-
1405
- # Export audio (download)
1406
- def export_audio(record_id):
1407
- if not record_id:
1408
- return None, "⚠️ Không có audio để export"
1409
-
1410
- history = load_history()
1411
- record = next((r for r in history if r.id == record_id), None)
1412
-
1413
- if not record or not record.audio_path:
1414
- return None, "❌ Không tìm thấy file audio"
1415
-
1416
- if not os.path.exists(record.audio_path):
1417
- return None, "❌ File audio đã bị xóa"
1418
-
1419
- return record.audio_path, f"✅ Đã export file audio #{record_id[:8]}"
1420
-
1421
- btn_export.click(
1422
- fn=export_audio,
1423
- inputs=history_id,
1424
- outputs=[history_audio, history_info]
1425
  )
1426
-
1427
- # Auto-refresh stats periodically
1428
- def auto_refresh_stats():
1429
- return get_processing_stats()
1430
-
1431
- # Refresh stats every time a tab is clicked
1432
- demo.load(
1433
- fn=auto_refresh_stats,
1434
- outputs=stats_display
1435
- )
1436
-
1437
- # Background processor thread
1438
- def background_processor():
1439
- """Xử lý queue tổng hợp trong background"""
1440
- global is_processing
1441
-
1442
- while True:
1443
- task = processing_queue.get()
1444
- if task is None:
1445
- break
1446
-
1447
- is_processing = True
1448
- text, voice, settings = task
1449
-
1450
- try:
1451
- print(f"[Background] Bắt đầu tổng hợp: {text[:50]}...")
1452
- result = synthesize_speech_internal(text, voice, settings)
1453
-
1454
- if result:
1455
- print(f"[Background] ✅ Hoàn thành: {result}")
1456
- else:
1457
- print(f"[Background] ❌ Thất bại")
1458
-
1459
- except Exception as e:
1460
- print(f"[Background] ❌ Lỗi: {e}")
1461
- import traceback
1462
- traceback.print_exc()
1463
-
1464
- is_processing = False
1465
- processing_queue.task_done()
1466
-
1467
- # Khởi động background thread
1468
- bg_thread = threading.Thread(target=background_processor, daemon=True)
1469
- bg_thread.start()
1470
 
1471
  if __name__ == "__main__":
1472
- print(f"\n{'='*70}")
1473
- print(f"🚀 VieNeu-TTS Studio Pro v{VERSION} đã sẵn sàng!")
1474
- print(f"{'='*70}")
1475
- print(f"📂 History Directory: {HISTORY_DIR}")
1476
- print(f"🎛️ Presets Directory: {SETTINGS_DIR}")
1477
- print(f"🎭 Voice Samples: {len(VOICE_SAMPLES)} giọng")
1478
- print(f"⚙️ Device Info: {DEVICE_INFO}")
1479
- print(f"📊 Default Presets: {len(DEFAULT_PRESETS)}")
1480
- print(f"{'='*70}")
1481
- print(f"\n🎯 Features:")
1482
- print(f" ✅ Advanced Voice Customization")
1483
- print(f" ✅ Preset Manager (Save/Load/Delete)")
1484
- print(f" ✅ Audio Effects (Speed/Pitch/Volume)")
1485
- print(f" ✅ Smart History with Search & Filter")
1486
- print(f" ✅ Settings Reuse from History")
1487
- print(f" ✅ Background Processing")
1488
- print(f" ✅ Thread-Safe Operations")
1489
- print(f" ✅ Auto-cleanup (100 records limit)")
1490
- print(f"\n🌐 Starting Gradio interface...")
1491
- print(f"{'='*70}\n")
1492
 
1493
- # Compatible với cả Gradio cũ và mới
1494
- try:
1495
- demo.queue(max_size=20).launch(
1496
- server_name="0.0.0.0",
1497
- server_port=7860,
1498
- show_error=True,
1499
- share=False
1500
- )
1501
- except Exception as e:
1502
- print(f"⚠️ Lỗi khởi động với queue, thử không queue: {e}")
1503
- demo.launch(
1504
- server_name="0.0.0.0",
1505
- server_port=7860,
1506
- show_error=True,
1507
- share=False
1508
- )
 
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ử và custom voices
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  try:
29
+ BASE_DIR = "./vieneu_data"
30
+ HISTORY_DIR = os.path.join(BASE_DIR, "tts_history")
31
+ CUSTOM_VOICES_DIR = os.path.join(BASE_DIR, "custom_voices")
32
+
33
  os.makedirs(HISTORY_DIR, exist_ok=True)
34
+ os.makedirs(CUSTOM_VOICES_DIR, exist_ok=True)
35
+
36
+ # Test write permission
37
+ test_file = os.path.join(BASE_DIR, ".test")
38
  with open(test_file, 'w') as f:
39
  f.write("test")
40
  os.remove(test_file)
41
  except (PermissionError, OSError):
42
+ # Fallback to temp directory
43
+ BASE_DIR = os.path.join(tempfile.gettempdir(), "vieneu_tts_data")
44
+ HISTORY_DIR = os.path.join(BASE_DIR, "tts_history")
45
+ CUSTOM_VOICES_DIR = os.path.join(BASE_DIR, "custom_voices")
46
+
47
  os.makedirs(HISTORY_DIR, exist_ok=True)
48
+ os.makedirs(CUSTOM_VOICES_DIR, exist_ok=True)
49
+ print(f"⚠️ Không có quyền ghi, sử dụng thư mục tạm: {BASE_DIR}")
50
 
51
  HISTORY_JSON = os.path.join(HISTORY_DIR, "history.json")
52
+ CUSTOM_VOICES_JSON = os.path.join(CUSTOM_VOICES_DIR, "voices.json")
 
53
 
54
  # Đường dẫn model
55
  BACKBONE_REPO = "pnnbao-ump/VieNeu-TTS-q8-gguf"
56
  CODEC_REPO = "neuphonic/neucodec-onnx-decoder"
57
 
58
+ # Giọng mẫu mặc định
59
+ DEFAULT_VOICE_SAMPLES = {
60
  "Tuyên (nam miền Bắc)": {
61
  "audio": "./sample/Tuyên (nam miền Bắc).wav",
62
  "text": "./sample/Tuyên (nam miền Bắc).txt",
63
+ "codes": "./sample/Tuyên (nam miền Bắc).pt",
64
+ "is_custom": False
65
  },
66
  "Vĩnh (nam miền Nam)": {
67
  "audio": "./sample/Vĩnh (nam miền Nam).wav",
68
  "text": "./sample/Vĩnh (nam miền Nam).txt",
69
+ "codes": "./sample/Vĩnh (nam miền Nam).pt",
70
+ "is_custom": False
71
  },
72
  "Bình (nam miền Bắc)": {
73
  "audio": "./sample/Bình (nam miền Bắc).wav",
74
  "text": "./sample/Bình (nam miền Bắc).txt",
75
+ "codes": "./sample/Bình (nam miền Bắc).pt",
76
+ "is_custom": False
77
  },
78
  "Nguyên (nam miền Nam)": {
79
  "audio": "./sample/Nguyên (nam miền Nam).wav",
80
  "text": "./sample/Nguyên (nam miền Nam).txt",
81
+ "codes": "./sample/Nguyên (nam miền Nam).pt",
82
+ "is_custom": False
83
  },
84
  "Sơn (nam miền Nam)": {
85
  "audio": "./sample/Sơn (nam miền Nam).wav",
86
  "text": "./sample/Sơn (nam miền Nam).txt",
87
+ "codes": "./sample/Sơn (nam miền Nam).pt",
88
+ "is_custom": False
89
  },
90
  "Đoan (nữ miền Nam)": {
91
  "audio": "./sample/Đoan (nữ miền Nam).wav",
92
  "text": "./sample/Đoan (nữ miền Nam).txt",
93
+ "codes": "./sample/Đoan (nữ miền Nam).pt",
94
+ "is_custom": False
95
  },
96
  "Ngọc (nữ miền Bắc)": {
97
  "audio": "./sample/Ngọc (nữ miền Bắc).wav",
98
  "text": "./sample/Ngọc (nữ miền Bắc).txt",
99
+ "codes": "./sample/Ngọc (nữ miền Bắc).pt",
100
+ "is_custom": False
101
  },
102
  "Ly (nữ miền Bắc)": {
103
  "audio": "./sample/Ly (nữ miền Bắc).wav",
104
  "text": "./sample/Ly (nữ miền Bắc).txt",
105
+ "codes": "./sample/Ly (nữ miền Bắc).pt",
106
+ "is_custom": False
107
  },
108
  "Dung (nữ miền Nam)": {
109
  "audio": "./sample/Dung (nữ miền Nam).wav",
110
  "text": "./sample/Dung (nữ miền Nam).txt",
111
+ "codes": "./sample/Dung (nữ miền Nam).pt",
112
+ "is_custom": False
113
  }
114
  }
115
 
116
+ VOICE_SAMPLES = DEFAULT_VOICE_SAMPLES.copy()
 
 
 
 
 
 
 
 
 
117
 
118
+ # --- CUSTOM VOICE MANAGEMENT ---
119
+ voices_lock = threading.Lock()
120
 
121
+ def load_custom_voices():
122
+ """Tải danh sách giọng custom từ file JSON"""
123
+ global VOICE_SAMPLES
124
+
125
+ with voices_lock:
126
+ if os.path.exists(CUSTOM_VOICES_JSON):
127
+ try:
128
+ with open(CUSTOM_VOICES_JSON, 'r', encoding='utf-8') as f:
129
+ custom_voices = json.load(f)
130
+
131
+ # Merge với default voices
132
+ VOICE_SAMPLES = DEFAULT_VOICE_SAMPLES.copy()
133
+ VOICE_SAMPLES.update(custom_voices)
134
+
135
+ print(f"✅ Đã tải {len(custom_voices)} giọng custom")
136
+ return True
137
+ except Exception as e:
138
+ print(f"⚠️ Lỗi đọc custom voices: {e}")
139
+ return False
140
+ return True
141
 
142
+ def save_custom_voices():
143
+ """Lưu danh sách giọng custom vào file JSON"""
144
+ with voices_lock:
145
  try:
146
+ # Chỉ lưu các giọng custom
147
+ custom_only = {k: v for k, v in VOICE_SAMPLES.items() if v.get("is_custom", False)}
148
+
149
+ with open(CUSTOM_VOICES_JSON, 'w', encoding='utf-8') as f:
150
+ json.dump(custom_only, f, ensure_ascii=False, indent=2)
151
+
152
+ return True
153
  except Exception as e:
154
+ print(f"⚠️ Lỗi ghi custom voices: {e}")
155
+ return False
156
+
157
+ def extract_audio_codes(audio_path, codec):
158
+ """Trích xuất codes từ file audio (placeholder - cần implement thật)"""
159
+ try:
160
+ # Load audio
161
+ audio, sr = librosa.load(audio_path, sr=SAMPLE_RATE)
162
+
163
+ # Placeholder: Tạo codes giả lập
164
+ # Trong thực tế, bạn cần encoder để tạo codes từ audio
165
+ # Ví dụ: codes = codec.encode(audio)
166
+
167
+ # Tạm thời sử dụng random codes để demo
168
+ duration_sec = len(audio) / SAMPLE_RATE
169
+ num_codes = int(duration_sec * 50) # ~50 codes/giây
170
+ codes = np.random.randint(0, 1024, size=num_codes)
171
+
172
+ return codes
173
+ except Exception as e:
174
+ print(f"❌ Lỗi trích xuất codes: {e}")
175
+ return None
176
 
177
+ def add_custom_voice(voice_name, audio_file, text_content):
178
+ """Thêm giọng custom mới"""
179
+ global VOICE_SAMPLES
 
180
 
181
+ if not voice_name or voice_name.strip() == "":
182
+ return False, "⚠️ Vui lòng nhập tên giọng"
183
+
184
+ voice_name = voice_name.strip()
185
+
186
+ # Kiểm tra trùng tên
187
+ if voice_name in VOICE_SAMPLES:
188
+ return False, f"⚠️ Giọng '{voice_name}' đã tồn tại"
189
+
190
+ if not audio_file:
191
+ return False, "⚠️ Vui lòng upload file audio"
192
+
193
+ if not text_content or text_content.strip() == "":
194
+ return False, "⚠️ Vui lòng nhập text tương ứng"
195
+
196
+ try:
197
+ # Tạo ID duy nhất cho giọng
198
+ voice_id = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
199
+ voice_dir = os.path.join(CUSTOM_VOICES_DIR, voice_id)
200
+ os.makedirs(voice_dir, exist_ok=True)
201
+
202
+ # Lưu audio
203
+ audio_dest = os.path.join(voice_dir, "audio.wav")
204
+ shutil.copy2(audio_file, audio_dest)
205
+
206
+ # Lưu text
207
+ text_dest = os.path.join(voice_dir, "text.txt")
208
+ with open(text_dest, 'w', encoding='utf-8') as f:
209
+ f.write(text_content.strip())
210
+
211
+ # Trích xuất codes (placeholder)
212
+ print(f"🔄 Đang trích xuất codes cho giọng '{voice_name}'...")
213
+ codes = extract_audio_codes(audio_file, codec)
214
+
215
+ if codes is None or len(codes) == 0:
216
+ shutil.rmtree(voice_dir)
217
+ return False, "❌ Không thể trích xuất codes từ audio"
218
+
219
+ # Lưu codes
220
+ codes_dest = os.path.join(voice_dir, "codes.pt")
221
+ torch.save(torch.from_numpy(codes), codes_dest)
222
+
223
+ # Thêm vào VOICE_SAMPLES
224
+ with voices_lock:
225
+ VOICE_SAMPLES[voice_name] = {
226
+ "audio": audio_dest,
227
+ "text": text_dest,
228
+ "codes": codes_dest,
229
+ "is_custom": True,
230
+ "voice_id": voice_id,
231
+ "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
232
+ }
233
+
234
+ # Lưu vào JSON
235
+ if save_custom_voices():
236
+ return True, f"✅ Đã thêm giọng '{voice_name}' thành công!"
237
+ else:
238
+ # Rollback nếu lưu thất bại
239
+ del VOICE_SAMPLES[voice_name]
240
+ shutil.rmtree(voice_dir)
241
+ return False, "❌ Lỗi khi lưu thông tin giọng"
242
+
243
+ except Exception as e:
244
+ import traceback
245
+ traceback.print_exc()
246
+ return False, f"❌ Lỗi: {str(e)}"
247
+
248
+ def delete_custom_voice(voice_name):
249
+ """Xóa giọng custom"""
250
+ global VOICE_SAMPLES
251
+
252
+ if voice_name not in VOICE_SAMPLES:
253
+ return False, "⚠️ Không tìm thấy giọng"
254
+
255
+ if not VOICE_SAMPLES[voice_name].get("is_custom", False):
256
+ return False, "⚠️ Không thể xóa giọng mặc định"
257
+
258
+ try:
259
+ voice_info = VOICE_SAMPLES[voice_name]
260
+ voice_id = voice_info.get("voice_id")
261
+
262
+ # Xóa thư mục chứa files
263
+ if voice_id:
264
+ voice_dir = os.path.join(CUSTOM_VOICES_DIR, voice_id)
265
+ if os.path.exists(voice_dir):
266
+ shutil.rmtree(voice_dir)
267
+
268
+ # Xóa khỏi VOICE_SAMPLES
269
+ with voices_lock:
270
+ del VOICE_SAMPLES[voice_name]
271
+
272
+ # Lưu lại JSON
273
+ save_custom_voices()
274
+
275
+ return True, f"✅ Đã xóa giọng '{voice_name}'"
276
+
277
+ except Exception as e:
278
+ return False, f"❌ Lỗi khi xóa: {str(e)}"
279
+
280
+ def get_voice_list():
281
+ """Lấy danh sách tất cả giọng"""
282
+ return list(VOICE_SAMPLES.keys())
283
+
284
+ def get_custom_voices_display():
285
+ """Hiển thị danh sách giọng custom dạng HTML"""
286
+ custom_voices = {k: v for k, v in VOICE_SAMPLES.items() if v.get("is_custom", False)}
287
+
288
+ if not custom_voices:
289
+ return """
290
+ <div style='padding: 20px; text-align: center; color: #64748b;'>
291
+ <p style='font-size: 1.1em;'>🎤 Chưa có giọng tùy chỉnh nào</p>
292
+ <p style='font-size: 0.9em;'>Thêm giọng mới bằng cách upload audio và text tương ứng</p>
293
+ </div>
294
+ """
295
+
296
+ html_parts = ["<div style='font-family: system-ui; line-height: 1.6;'>"]
297
+
298
+ for i, (name, info) in enumerate(custom_voices.items(), 1):
299
+ created_at = info.get("created_at", "N/A")
300
+ voice_id = info.get("voice_id", "N/A")
301
+
302
+ html_parts.append(f"""
303
+ <div style='
304
+ background: white;
305
+ border: 1px solid #e2e8f0;
306
+ border-radius: 8px;
307
+ padding: 15px;
308
+ margin-bottom: 12px;
309
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
310
+ '>
311
+ <div style='display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;'>
312
+ <div style='font-weight: 600; color: #1e293b; font-size: 0.95em;'>
313
+ <span style='color: #64748b;'>#{i}</span> {name}
314
+ </div>
315
+ <div style='font-size: 0.85em; color: #64748b;'>
316
+ {created_at}
317
+ </div>
318
+ </div>
319
+
320
+ <div style='display: flex; gap: 20px; font-size: 0.85em; color: #64748b;'>
321
+ <div>🎤 Giọng tùy chỉnh</div>
322
+ <div style='margin-left: auto; color: #3b82f6;'>
323
+ ID: {voice_id[:13]}...
324
+ </div>
325
+ </div>
326
+ </div>
327
+ """)
328
+
329
+ html_parts.append("</div>")
330
+ return "".join(html_parts)
331
+
332
+ # Load custom voices khi khởi động
333
+ load_custom_voices()
334
 
335
  # --- HISTORY MANAGEMENT ---
336
+ history_lock = threading.Lock()
337
+
338
+ def load_history():
339
+ """Tải lịch sử từ file JSON - Thread-safe"""
340
  with history_lock:
341
  if os.path.exists(HISTORY_JSON):
342
  try:
343
  with open(HISTORY_JSON, 'r', encoding='utf-8') as f:
344
+ return json.load(f)
 
345
  except Exception as e:
346
  print(f"⚠️ Lỗi đọc history.json: {e}")
347
  return []
348
  return []
349
 
350
+ def save_history(history):
351
+ """Lưu lịch sử vào file JSON - Thread-safe"""
352
  with history_lock:
353
  try:
 
354
  with open(HISTORY_JSON, 'w', encoding='utf-8') as f:
355
+ json.dump(history, f, ensure_ascii=False, indent=2)
356
  except Exception as e:
357
  print(f"⚠️ Lỗi ghi history.json: {e}")
358
 
359
+ def add_to_history(text, voice, audio_path, duration, status):
360
+ """Thêm một bản ghi vào lịch sử - Thread-safe"""
 
 
 
 
 
361
  try:
362
  history = load_history()
363
 
 
374
  else:
375
  permanent_path = None
376
 
377
+ record = {
378
+ "id": timestamp,
379
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
380
+ "text": text[:100] + "..." if len(text) > 100 else text,
381
+ "full_text": text,
382
+ "voice": voice,
383
+ "audio_path": permanent_path,
384
+ "duration": duration,
385
+ "status": status
386
+ }
 
 
387
 
388
  history.insert(0, record)
389
 
 
390
  if len(history) > 100:
391
  old_record = history.pop()
392
  try:
393
+ if old_record.get('audio_path') and os.path.exists(old_record['audio_path']):
394
+ os.remove(old_record['audio_path'])
395
  except Exception as e:
396
  print(f"⚠️ Không thể xóa file cũ: {e}")
397
 
 
403
  traceback.print_exc()
404
  return audio_path if audio_path else None
405
 
406
+ def get_history_list():
407
+ """Lấy danh sách lịch sử để hiển thị dạng HTML"""
408
  history = load_history()
 
 
 
 
 
 
 
409
  if not history:
410
  return """
411
  <div style='padding: 20px; text-align: center; color: #64748b;'>
412
+ <p style='font-size: 1.1em;'>📭 Chưa lịch sử tổng hợp nào</p>
413
+ <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>
414
  </div>
415
  """
416
 
417
  html_parts = ["<div style='font-family: system-ui; line-height: 1.6;'>"]
418
 
419
  for i, record in enumerate(history[:50], 1):
420
+ status_color = "#10b981" if record['status'] == "Thành công" else "#ef4444"
421
+ status_icon = "✅" if record['status'] == "Thành công" else "❌"
 
 
 
 
 
 
 
 
 
422
 
423
  html_parts.append(f"""
424
  <div style='
 
428
  padding: 15px;
429
  margin-bottom: 12px;
430
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
431
+ '>
 
 
432
  <div style='display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;'>
433
  <div style='font-weight: 600; color: #1e293b; font-size: 0.95em;'>
434
+ <span style='color: #64748b;'>#{i}</span> {record['voice']}
 
435
  </div>
436
  <div style='font-size: 0.85em; color: #64748b;'>
437
+ {record['timestamp']}
438
  </div>
439
  </div>
440
 
 
447
  font-size: 0.9em;
448
  border-left: 3px solid #3b82f6;
449
  '>
450
+ {record['text']}
451
  </div>
452
 
453
  <div style='display: flex; gap: 20px; font-size: 0.85em; color: #64748b;'>
454
+ <div>⏱️ {record['duration']:.2f}s</div>
455
  <div style='color: {status_color}; font-weight: 500;'>
456
+ {status_icon} {record['status']}
457
  </div>
458
+ <div style='margin-left: auto; color: #3b82f6; cursor: pointer;'>
459
+ ID: {record['id'][:13]}...
460
  </div>
461
  </div>
462
  </div>
 
468
  def clear_all_history():
469
  """Xóa toàn bộ lịch sử"""
470
  history = load_history()
471
+
472
  for record in history:
473
  try:
474
+ if record.get('audio_path') and os.path.exists(record['audio_path']):
475
+ os.remove(record['audio_path'])
476
  except Exception as e:
477
  print(f"⚠️ Không thể xóa file: {e}")
478
+
479
  save_history([])
480
  return "✅ Đã xóa toàn bộ lịch sử"
481
 
482
+ # --- BACKGROUND PROCESSING QUEUE ---
483
  processing_queue = Queue()
484
  is_processing = False
485
  processing_stats = {"total": 0, "success": 0, "failed": 0}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
 
487
+ def background_processor():
488
+ """Xử queue tổng hợp trong background"""
489
+ global is_processing, processing_stats
490
+
491
+ while True:
492
+ task = processing_queue.get()
493
+ if task is None:
494
+ break
495
 
496
+ is_processing = True
497
+ text, voice = task
 
 
 
 
 
498
 
499
+ try:
500
+ print(f"[Background] Bắt đầu tổng hợp: {text[:50]}...")
501
+ result = synthesize_speech_internal(text, voice)
502
+
503
+ if result:
504
+ processing_stats["success"] += 1
505
+ print(f"[Background] ✅ Hoàn thành: {result}")
506
+ else:
507
+ processing_stats["failed"] += 1
508
+ print(f"[Background] ❌ Thất bại")
509
+
510
+ processing_stats["total"] += 1
511
+
512
+ except Exception as e:
513
+ processing_stats["failed"] += 1
514
+ processing_stats["total"] += 1
515
+ print(f"[Background] ❌ Lỗi: {e}")
516
+ import traceback
517
+ traceback.print_exc()
518
 
519
+ is_processing = False
520
+ processing_queue.task_done()
521
+
522
+ bg_thread = threading.Thread(target=background_processor, daemon=True)
523
+ bg_thread.start()
524
+
525
+ def get_processing_stats():
526
+ """Lấy thống kê xử lý"""
527
+ return f"📊 Tổng: {processing_stats['total']} | ✅ Thành công: {processing_stats['success']} | ❌ Thất bại: {processing_stats['failed']}"
528
 
529
+ def split_text_into_chunks(text, max_chars=256):
530
+ """Chia text thành chunks"""
531
  sentences = re.split(r'([.!?,;])', text)
532
  chunks = []
533
  current = ""
 
549
 
550
  return chunks if chunks else [text]
551
 
552
+ def decode_audio(codes_str, codec):
553
  """Decode speech tokens to audio"""
554
  speech_ids = [int(num) for num in re.findall(r"<\|speech_(\d+)\|>", codes_str)]
555
 
 
588
  print(f"❌ Lỗi khi tải model: {e}")
589
  model_loaded = False
590
 
591
+ # --- SYNTHESIS FUNCTION (Internal) ---
592
+ def synthesize_speech_internal(text, voice_choice):
593
+ """Internal synthesis function không phụ thuộc UI"""
 
594
  global backbone, codec, model_loaded
595
 
596
+ if not model_loaded:
597
+ print("❌ Model chưa được tải")
598
+ return None
599
+
600
+ if not text or text.strip() == "":
601
+ print("❌ Text rỗng")
602
+ return None
603
+
604
+ if voice_choice not in VOICE_SAMPLES:
605
+ print(f"❌ Giọng không hợp lệ: {voice_choice}")
606
  return None
607
 
608
  raw_text = text.strip()
609
 
 
610
  ref_text_path = VOICE_SAMPLES[voice_choice]["text"]
611
  try:
612
  with open(ref_text_path, "r", encoding="utf-8") as f:
 
615
  print(f"❌ Lỗi đọc file text mẫu: {e}")
616
  return None
617
 
618
+ ref_codes = None
619
  codes_path = VOICE_SAMPLES[voice_choice]["codes"]
620
+
621
  try:
622
  ref_codes_tensor = torch.load(codes_path, map_location="cpu")
623
  if isinstance(ref_codes_tensor, torch.Tensor):
 
629
  return None
630
 
631
  if ref_codes is None or len(ref_codes) == 0:
632
+ print("❌ Codes rỗng")
633
  return None
634
 
 
635
  text_chunks = split_text_into_chunks(raw_text, max_chars=MAX_CHARS_PER_CHUNK)
636
+
637
  all_audio_segments = []
638
+ silence_pad = np.zeros(int(SAMPLE_RATE * 0.15), dtype=np.float32)
639
 
640
  start_time = time.time()
641
 
 
643
  for i, chunk in enumerate(text_chunks):
644
  print(f"[Internal] Xử lý chunk {i+1}/{len(text_chunks)}")
645
 
 
646
  ref_text_phoneme = phonemize_with_dict(ref_text_raw)
647
  input_text_phoneme = phonemize_with_dict(chunk)
648
 
 
649
  codes_str = "".join([f"<|speech_{idx}|>" for idx in ref_codes])
650
+
651
  prompt = (
652
  f"user: Convert the text to speech:<|TEXT_PROMPT_START|>{ref_text_phoneme} {input_text_phoneme}"
653
  f"<|TEXT_PROMPT_END|>\nassistant:<|SPEECH_GENERATION_START|>{codes_str}"
654
  )
655
 
 
656
  output = backbone(
657
  prompt,
658
  max_tokens=2048,
659
+ temperature=1.0,
660
+ top_k=50,
 
661
  stop=["<|SPEECH_GENERATION_END|>"],
662
  )
663
  output_str = output["choices"][0]["text"]
664
 
 
665
  chunk_wav = decode_audio(output_str, codec)
666
 
 
 
 
667
  if chunk_wav is not None and len(chunk_wav) > 0:
668
  all_audio_segments.append(chunk_wav)
669
  if i < len(text_chunks) - 1:
670
  all_audio_segments.append(silence_pad)
671
 
672
  if not all_audio_segments:
673
+ print(" Không audio segment nào")
674
+ add_to_history(raw_text, voice_choice, None, 0, "Lỗi: Không sinh được audio")
675
  return None
676
 
677
  final_wav = np.concatenate(all_audio_segments)
 
680
  output_path = tmp.name
681
 
682
  process_time = time.time() - start_time
 
683
 
684
+ permanent_path = add_to_history(raw_text, voice_choice, output_path, process_time, "Thành công")
685
+
686
  print(f"✅ Hoàn thành: {permanent_path}")
687
  return permanent_path
688
 
689
  except Exception as e:
690
  import traceback
691
  traceback.print_exc()
692
+ add_to_history(raw_text, voice_choice, None, 0, f"Lỗi: {str(e)}")
 
693
  return None
694
 
695
+ # --- SYNTHESIS FUNCTION (UI) ---
696
+ def synthesize_speech(text, voice_choice):
 
 
697
  """Main synthesis function với UI feedback"""
698
  global backbone, codec, model_loaded
699
 
 
709
  yield None, "⚠️ Vui lòng chọn giọng mẫu."
710
  return
711
 
 
 
 
 
 
 
 
 
 
 
 
712
  raw_text = text.strip()
713
 
 
714
  ref_text_path = VOICE_SAMPLES[voice_choice]["text"]
715
  try:
716
  with open(ref_text_path, "r", encoding="utf-8") as f:
 
721
 
722
  yield None, "📄 Đang xử lý Reference..."
723
 
724
+ ref_codes = None
725
  codes_path = VOICE_SAMPLES[voice_choice]["codes"]
726
+
727
  try:
728
  ref_codes_tensor = torch.load(codes_path, map_location="cpu")
729
  if isinstance(ref_codes_tensor, torch.Tensor):
 
738
  yield None, "❌ Codes tham chiếu không hợp lệ."
739
  return
740
 
 
741
  text_chunks = split_text_into_chunks(raw_text, max_chars=MAX_CHARS_PER_CHUNK)
742
  total_chunks = len(text_chunks)
743
 
744
  yield None, f"🚀 Bắt đầu tổng hợp ({total_chunks} đoạn)..."
745
 
746
  all_audio_segments = []
747
+ silence_pad = np.zeros(int(SAMPLE_RATE * 0.15), dtype=np.float32)
748
 
749
  start_time = time.time()
750
 
 
752
  for i, chunk in enumerate(text_chunks):
753
  yield None, f"⏳ Đang xử lý đoạn {i+1}/{total_chunks}... ({int((i/total_chunks)*100)}%)"
754
 
 
755
  ref_text_phoneme = phonemize_with_dict(ref_text_raw)
756
  input_text_phoneme = phonemize_with_dict(chunk)
757
 
 
758
  codes_str = "".join([f"<|speech_{idx}|>" for idx in ref_codes])
759
+
760
  prompt = (
761
  f"user: Convert the text to speech:<|TEXT_PROMPT_START|>{ref_text_phoneme} {input_text_phoneme}"
762
  f"<|TEXT_PROMPT_END|>\nassistant:<|SPEECH_GENERATION_START|>{codes_str}"
763
  )
764
 
 
765
  output = backbone(
766
  prompt,
767
  max_tokens=2048,
768
+ temperature=1.0,
769
+ top_k=50,
 
770
  stop=["<|SPEECH_GENERATION_END|>"],
771
  )
772
  output_str = output["choices"][0]["text"]
773
 
 
774
  chunk_wav = decode_audio(output_str, codec)
775
 
 
 
 
 
776
  if chunk_wav is not None and len(chunk_wav) > 0:
777
  all_audio_segments.append(chunk_wav)
778
  if i < total_chunks - 1:
 
780
 
781
  if not all_audio_segments:
782
  yield None, "❌ Không sinh được audio nào."
783
+ add_to_history(raw_text, voice_choice, None, 0, "Lỗi: Không sinh được audio")
 
784
  return
785
 
786
  yield None, "💾 Đang ghép file và lưu..."
 
791
  output_path = tmp.name
792
 
793
  process_time = time.time() - start_time
 
 
794
 
795
+ permanent_path = add_to_history(raw_text, voice_choice, output_path, process_time, "Thành công")
 
 
796
 
797
+ 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})"
 
798
 
799
  except Exception as e:
800
  import traceback
801
  traceback.print_exc()
802
+ add_to_history(raw_text, voice_choice, None, 0, f"Lỗi: {str(e)}")
 
803
  yield None, f"❌ Lỗi tổng hợp: {str(e)}"
804
 
805
+ def refresh_history():
806
+ """Làm mới danh sách lịch sử"""
807
+ return get_history_list()
808
+
809
+ def load_history_item(item_index):
810
+ """Tải một item từ lịch sử theo số thứ tự"""
811
  if not item_index or item_index.strip() == "":
812
+ return None, "", "", "⚠️ Vui lòng nhập số thứ tự"
813
 
814
  try:
815
  index = int(item_index.strip()) - 1
816
  history = load_history()
817
 
818
  if index < 0 or index >= len(history):
819
+ return None, "", "", f"❌ Số thứ tự không hợp lệ (1-{len(history)})"
820
 
821
  record = history[index]
822
 
823
  audio_path = None
824
+ if record.get('audio_path') and os.path.exists(record['audio_path']):
825
+ audio_path = record['audio_path']
826
 
827
  info = f"""
828
+ 📅 Thời gian: {record['timestamp']}
829
+ ⏱️ Thời lượng: {record['duration']:.2f}s
830
+ 🎭 Giọng: {record['voice']}
831
+ 📊 Trạng thái: {record['status']}
832
+ 🆔 ID: {record['id']}
 
 
 
 
 
 
 
 
 
833
  """.strip()
834
 
835
+ return audio_path, record['full_text'], record['voice'], info
836
 
837
  except ValueError:
838
+ return None, "", "", "❌ Vui lòng nhập số hợp lệ"
839
  except Exception as e:
840
+ return None, "", "", f"❌ Lỗi: {str(e)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
841
 
842
  # --- UI SETUP ---
843
  theme = gr.themes.Ocean(
 
851
  )
852
 
853
  css = """
854
+ .container { max-width: 1400px; margin: auto; }
855
  .header-box {
856
  text-align: center;
857
  margin-bottom: 25px;
 
859
  background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
860
  border-radius: 12px;
861
  color: white;
 
862
  }
863
  .header-title {
864
+ font-size: 2.5rem;
865
  font-weight: 800;
 
866
  }
867
  .gradient-text {
868
+ background: -webkit-linear-gradient(45deg, #60A5FA, #22D3EE);
869
  -webkit-background-clip: text;
870
  -webkit-text-fill-color: transparent;
 
871
  }
872
  .status-box {
873
  font-weight: bold;
 
875
  border: none;
876
  background: transparent;
877
  }
878
+ .model-card-content {
879
+ display: flex;
880
+ flex-wrap: wrap;
881
+ justify-content: center;
882
+ align-items: center;
883
+ gap: 15px;
884
+ font-size: 0.9rem;
885
+ color: #cbd5e1;
886
+ }
887
+ .model-card-item {
888
+ display: flex;
889
+ align-items: center;
890
+ justify-content: center;
891
+ gap: 6px;
892
+ color: #94a3b8;
893
+ }
894
+ .model-card-link {
895
+ color: #3b82f6;
896
+ text-decoration: none;
897
+ font-weight: 500;
898
+ transition: color 0.2s;
899
+ }
900
+ .model-card-link:hover {
901
+ color: #2563eb;
902
+ text-decoration: underline;
903
  }
904
  .history-container {
905
  max-height: 650px;
 
910
  }
911
  .info-box {
912
  background: #f8fafc;
913
+ padding: 12px;
914
+ border-radius: 6px;
915
  border-left: 4px solid #3b82f6;
916
  margin: 10px 0;
 
 
 
 
 
 
 
 
 
 
 
 
917
  }
918
  """
919
 
 
923
  ["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)"],
924
  ]
925
 
926
+ 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."
 
927
 
928
+ with gr.Blocks(title="VieNeu-TTS", theme=theme, css=css) as demo:
929
  with gr.Column(elem_classes="container"):
930
  gr.HTML(f"""
931
  <div class="header-box">
932
  <h1 class="header-title">
933
+ <span class="header-icon">🦜</span>
934
+ <span class="gradient-text">VieNeu-TTS Studio</span>
935
  </h1>
936
+ <p style="margin: 10px 0;">Chế độ: {DEVICE_INFO} | Background Processing ✅ | History ✅ | Custom Voices ✅</p>
937
+ <p style="font-size: 0.85rem; color: #94a3b8;">📁 Dữ liệu lưu tại: {BASE_DIR}</p>
938
+ <div class="model-card-content">
939
+ <div class="model-card-item">
940
+ <strong>Repository:</strong>
941
+ <a href="https://github.com/pnnbao97/VieNeu-TTS" target="_blank" class="model-card-link">GitHub</a>
942
+ </div>
943
+ <div class="model-card-item">
944
+ <strong>Tác giả:</strong>
945
+ <span>Phạm Nguyễn Ngọc Bảo</span>
946
+ </div>
 
947
  </div>
948
  </div>
949
  """)
 
952
 
953
  # --- TABS ---
954
  with gr.Tabs():
955
+ # TAB 1: Tổng hợp
956
+ with gr.Tab("🎙️ Tổng hợp"):
957
+ with gr.Row(elem_classes="container"):
958
+ with gr.Column(scale=3):
959
  text_input = gr.Textbox(
960
+ label=f"📝 Văn bản (Chia chunk: {MAX_CHARS_PER_CHUNK} ký tự)",
961
+ lines=7,
962
  placeholder="Nhập văn bản tiếng Việt cần tổng hợp...",
963
+ 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.",
964
  )
965
 
966
  voice_select = gr.Dropdown(
967
+ choices=get_voice_list(),
968
+ value=get_voice_list()[0] if get_voice_list() else None,
969
  label="👤 Chọn giọng mẫu",
970
  )
971
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
972
  with gr.Row():
973
+ btn_generate = gr.Button("🎵 Bắt đầu tổng hợp", variant="primary", size="lg", interactive=model_loaded)
974
+ btn_clear = gr.Button("🗑️ Xóa", variant="secondary", size="lg")
 
 
 
 
 
 
975
 
976
+ with gr.Column(scale=2):
977
  audio_output = gr.Audio(
978
  label="🔊 Kết quả",
979
  type="filepath",
 
983
  label="📊 Trạng thái",
984
  elem_classes="status-box",
985
  value="Chờ nhập văn bản...",
986
+ lines=2
987
  )
 
 
 
 
 
 
 
 
 
988
 
989
  gr.Examples(
990
  examples=EXAMPLES_LIST,
991
  inputs=[text_input, voice_select],
992
+ outputs=[audio_output, status_output],
993
+ fn=synthesize_speech,
994
+ cache_examples=False,
995
  label="💡 Các ví dụ nhanh"
996
  )
997
 
998
+ # TAB 2: Quản giọng
999
+ with gr.Tab("🎤 Quản giọng"):
1000
+ gr.Markdown("### ➕ Thêm giọng tùy chỉnh")
1001
+
1002
  with gr.Row():
1003
+ with gr.Column(scale=2):
1004
+ new_voice_name = gr.Textbox(
1005
+ label="📝 Tên giọng",
1006
+ placeholder="VD: Mai (nữ miền Trung)",
1007
+ info="Đặt tên mô tả cho giọng mới"
1008
+ )
1009
+
1010
+ new_voice_audio = gr.Audio(
1011
+ label="🎵 File audio mẫu (WAV)",
1012
+ type="filepath",
1013
+ sources=["upload"]
1014
+ )
1015
+
1016
+ new_voice_text = gr.Textbox(
1017
+ label="📄 Text tương ứng với audio",
1018
+ lines=5,
1019
+ placeholder="Nhập chính xác nội dung trong file audio...",
1020
+ info="Text này phải khớp chính xác với audio mẫu"
1021
+ )
1022
 
1023
  with gr.Row():
1024
+ btn_add_voice = gr.Button("➕ Thêm giọng", variant="primary", size="lg")
1025
+ btn_refresh_voices = gr.Button("🔄 Làm mới", variant="secondary", size="sm")
1026
+
1027
+ add_voice_status = gr.Textbox(
1028
+ label="📊 Trạng thái",
1029
+ value="Chờ thêm giọng...",
1030
+ interactive=False
1031
+ )
1032
+
1033
+ with gr.Column(scale=1):
1034
+ gr.Markdown("### 💡 Hướng dẫn")
1035
+ gr.Markdown("""
1036
+ **Yêu cầu file audio:**
1037
+ - Format: WAV (khuyến nghị)
1038
+ - Sample rate: 24kHz (tối ưu)
1039
+ - Độ dài: 5-30 giây
1040
+ - Chất lượng: Rõ ràng, ít nhiễu
1041
+
1042
+ **Lưu ý:**
1043
+ - Text phải khớp chính xác với audio
1044
+ - Giọng mẫu càng rõ, kết quả càng tốt
1045
+ - Tránh nhiễu và echo
1046
+ - Nói tự nhiên, không quá nhanh/chậm
1047
+
1048
+ **⚠️ Chú ý:**
1049
+ Quá trình trích xuất codes có thể mất vài giây
1050
+ """)
1051
+
1052
+ gr.Markdown("---")
1053
+ gr.Markdown("### 📋 Danh sách giọng tùy chỉnh")
1054
+
1055
+ with gr.Row():
1056
+ with gr.Column(scale=3):
1057
+ custom_voices_display = gr.HTML(
1058
+ value=get_custom_voices_display(),
1059
+ elem_classes="history-container"
1060
+ )
1061
+
1062
+ with gr.Column(scale=2):
1063
+ gr.Markdown("### 🗑️ Xóa giọng")
1064
+
1065
+ delete_voice_select = gr.Dropdown(
1066
+ choices=[v for v in get_voice_list() if VOICE_SAMPLES[v].get("is_custom", False)],
1067
+ label="Chọn giọng cần xóa",
1068
+ info="Chỉ hiển thị giọng tùy chỉnh"
1069
+ )
1070
+
1071
+ btn_delete_voice = gr.Button("🗑️ Xóa giọng", variant="stop")
1072
+
1073
+ delete_voice_status = gr.Textbox(
1074
+ label="📊 Trạng thái",
1075
+ interactive=False
1076
+ )
1077
+
1078
+ gr.Markdown(f"""
1079
+ ### 📊 Thống kê
1080
+ - **Giọng mặc định**: {len([v for v in VOICE_SAMPLES.values() if not v.get('is_custom', False)])}
1081
+ - **Giọng tùy chỉnh**: {len([v for v in VOICE_SAMPLES.values() if v.get('is_custom', False)])}
1082
+ - **Tổng**: {len(VOICE_SAMPLES)}
1083
+ """)
1084
+
1085
+ # TAB 3: Lịch sử
1086
+ with gr.Tab("📜 Lịch sử"):
1087
+ with gr.Row():
1088
+ with gr.Column(scale=3):
1089
+ gr.Markdown("### 📋 Danh sách lịch sử")
1090
 
1091
  with gr.Row():
1092
  btn_refresh = gr.Button("🔄 Làm mới", size="sm", variant="secondary")
 
1109
 
1110
  with gr.Row():
1111
  history_select = gr.Textbox(
1112
+ label="Nhập số thứ tự (vd: 1, 2, 3...)",
1113
+ placeholder="Nhập số...",
1114
  scale=3
1115
  )
1116
  btn_load_history = gr.Button("📂 Tải", variant="primary", scale=1)
1117
 
 
 
 
 
 
 
1118
  history_info = gr.Textbox(
1119
+ label="ℹ️ Thông tin",
1120
+ lines=6,
1121
  elem_classes="info-box"
1122
  )
1123
 
 
1138
  )
1139
 
1140
  with gr.Row():
1141
+ btn_reuse = gr.Button("♻️ Tái sử dụng văn bản", variant="secondary")
 
1142
 
1143
+ # TAB 4: Thông tin
1144
+ with gr.Tab("ℹ️ Thông tin"):
1145
+ gr.Markdown(f"""
1146
+ ## 🎯 Về VieNeu-TTS
1147
+
1148
+ **VieNeu-TTS** 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.
1149
+
1150
+ ### ⚙️ Cấu hình hiện tại
1151
+ - **Model Backbone**: Q4 GGUF (llama-cpp)
1152
+ - **Codec**: ONNX Decoder
1153
+ - **Sample Rate**: {SAMPLE_RATE} Hz
1154
+ - **Max Chunk Size**: {MAX_CHARS_PER_CHUNK} ký tự
1155
+ - **Thư mục dữ liệu**: `{BASE_DIR}`
1156
+
1157
+ ### 🎭 Giọng mẫu
1158
+ - **Giọng mặc định**: {len([v for v in VOICE_SAMPLES.values() if not v.get('is_custom', False)])}
1159
+ - **Giọng tùy chỉnh**: {len([v for v in VOICE_SAMPLES.values() if v.get('is_custom', False)])}
1160
+ - **Tổng cộng**: {len(VOICE_SAMPLES)} giọng
1161
+
1162
+ ### 📌 Hướng dẫn sử dụng
1163
+
1164
+ **1. Tổng hợp giọng nói**:
1165
+ - Nhập văn bản vào ô "Văn bản"
1166
+ - Chọn giọng mẫu phù hợp
1167
+ - Nhấn "Bắt đầu tổng hợp"
1168
+ - Đợi hệ thống xử nghe kết quả
1169
+
1170
+ **2. Thêm giọng tùy chỉnh**:
1171
+ - Vào tab "Quản giọng"
1172
+ - Nhập tên giọng (VD: "Hùng (nam miền Trung)")
1173
+ - Upload file audio mẫu (WAV, 5-30s, rõ ràng)
1174
+ - Nhập text tương ứng chính xác
1175
+ - Nhấn "Thêm giọng" đợi xử lý
1176
+ - Giọng mới sẽ xuất hiện trong dropdown
1177
+
1178
+ **3. Xem lịch sử**:
1179
+ - Vào tab "Lịch sử"
1180
+ - Nhấn "Làm mới" để cập nhật danh sách
1181
+ - Nhập số thứ tự và nhấn "Tải" để xem chi tiết
1182
+
1183
+ **4. Xóa giọng tùy chỉnh**:
1184
+ - Vào tab "Quản lý giọng"
1185
+ - Chọn giọng cần xóa từ dropdown
1186
+ - Nhấn "Xóa giọng" (chỉ xóa được giọng tùy chỉnh)
1187
+
1188
+ ### 🔧 Tính năng nâng cao
1189
+ - Xử lý background (không phụ thuộc UI)
1190
+ - Lưu lịch sử tự động
1191
+ - Quản giọng tùy chỉnh
1192
+ - Persistent storage (giữ dữ liệu sau khi tắt)
1193
+ - Chia chunk thông minh
1194
+ - ✅ Thread-safe operations
1195
+ - Tự động xóa file cũ khi vượt quá 100 bản ghi
1196
+
1197
+ ### 📊 Thống
1198
+ {get_processing_stats()}
1199
+
1200
+ ### 🔗 Liên kết
1201
+ - [GitHub Repository](https://github.com/pnnbao97/VieNeu-TTS)
1202
+ - [Model Hub](https://huggingface.co/pnnbao-ump)
1203
+
1204
+ ---
1205
+ **Phiên bản**: 3.0 Custom Voice | **Tác giả**: Phạm Nguyễn Ngọc Bảo
1206
+ """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1207
 
1208
+ # Event handlers - Tab Tổng hợp
 
 
1209
  btn_generate.click(
1210
  fn=synthesize_speech,
1211
+ inputs=[text_input, voice_select],
 
 
 
 
1212
  outputs=[audio_output, status_output]
1213
  )
1214
 
 
1217
  outputs=[text_input, audio_output, status_output]
1218
  )
1219
 
1220
+ # Event handlers - Tab Quản lý giọng
1221
+ def handle_add_voice(name, audio, text):
1222
+ success, message = add_custom_voice(name, audio, text)
1223
+
1224
+ if success:
1225
+ # Cập nhật dropdown và display
1226
+ new_choices = get_voice_list()
1227
+ custom_display = get_custom_voices_display()
1228
+ delete_choices = [v for v in new_choices if VOICE_SAMPLES[v].get("is_custom", False)]
1229
+
1230
+ return (
1231
+ message, # add_voice_status
1232
+ gr.update(choices=new_choices), # voice_select (tab 1)
1233
+ custom_display, # custom_voices_display
1234
+ gr.update(choices=delete_choices), # delete_voice_select
1235
+ "", # new_voice_name
1236
+ None, # new_voice_audio
1237
+ "" # new_voice_text
1238
+ )
1239
+ else:
1240
+ return (
1241
+ message,
1242
+ gr.update(),
1243
+ gr.update(),
1244
+ gr.update(),
1245
+ gr.update(),
1246
+ gr.update(),
1247
+ gr.update()
1248
+ )
1249
 
1250
+ btn_add_voice.click(
1251
+ fn=handle_add_voice,
1252
+ inputs=[new_voice_name, new_voice_audio, new_voice_text],
1253
  outputs=[
1254
+ add_voice_status,
1255
+ voice_select,
1256
+ custom_voices_display,
1257
+ delete_voice_select,
1258
+ new_voice_name,
1259
+ new_voice_audio,
1260
+ new_voice_text
1261
  ]
1262
  )
1263
 
1264
+ def handle_delete_voice(voice_name):
1265
+ if not voice_name:
1266
+ return "⚠️ Vui lòng chọn giọng", gr.update(), gr.update(), gr.update()
1267
+
1268
+ success, message = delete_custom_voice(voice_name)
1269
+
1270
+ if success:
1271
+ new_choices = get_voice_list()
1272
+ custom_display = get_custom_voices_display()
1273
+ delete_choices = [v for v in new_choices if VOICE_SAMPLES[v].get("is_custom", False)]
1274
+
1275
+ return (
1276
+ message,
1277
+ gr.update(choices=new_choices),
1278
+ custom_display,
1279
+ gr.update(choices=delete_choices, value=None)
1280
+ )
1281
+ else:
1282
+ return message, gr.update(), gr.update(), gr.update()
1283
+
1284
+ btn_delete_voice.click(
1285
+ fn=handle_delete_voice,
1286
+ inputs=delete_voice_select,
1287
  outputs=[
1288
+ delete_voice_status,
1289
+ voice_select,
1290
+ custom_voices_display,
1291
+ delete_voice_select
1292
  ]
1293
  )
1294
 
1295
+ def refresh_all_voices():
1296
+ custom_display = get_custom_voices_display()
1297
+ delete_choices = [v for v in get_voice_list() if VOICE_SAMPLES[v].get("is_custom", False)]
1298
+
1299
+ return (
1300
+ custom_display,
1301
+ gr.update(choices=delete_choices),
1302
+ "✅ Đã làm mới"
1303
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1304
 
1305
+ btn_refresh_voices.click(
1306
+ fn=refresh_all_voices,
1307
+ outputs=[custom_voices_display, delete_voice_select, add_voice_status]
 
1308
  )
1309
 
1310
+ # Event handlers - Tab Lịch sử
1311
  btn_refresh.click(
1312
+ fn=refresh_history,
1313
  outputs=history_display
1314
  ).then(
1315
  fn=get_processing_stats,
 
1319
  btn_load_history.click(
1320
  fn=load_history_item,
1321
  inputs=history_select,
1322
+ outputs=[history_audio, history_text, history_voice, history_info]
1323
  )
1324
 
1325
  def clear_all():
 
1331
  outputs=[history_display, stats_display]
1332
  )
1333
 
1334
+ def reuse_text(text, voice):
1335
+ return text, voice, "✅ Đã tải văn bản vào tab Tổng hợp"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1336
 
1337
  btn_reuse.click(
1338
+ fn=reuse_text,
1339
+ inputs=[history_text, history_voice],
1340
+ outputs=[text_input, voice_select, status_output]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1341
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1342
 
1343
  if __name__ == "__main__":
1344
+ print(f"\n{'='*60}")
1345
+ print(f"🚀 VieNeu-TTS Studio đã sẵn sàng!")
1346
+ print(f"📂 Dữ liệu lưu tại: {BASE_DIR}")
1347
+ print(f"📜 Lịch sử: {HISTORY_DIR}")
1348
+ print(f"🎤 Giọng tùy chỉnh: {CUSTOM_VOICES_DIR}")
1349
+ print(f"🎭 Tổng số giọng: {len(VOICE_SAMPLES)} ({len([v for v in VOICE_SAMPLES.values() if not v.get('is_custom', False)])} mặc định + {len([v for v in VOICE_SAMPLES.values() if v.get('is_custom', False)])} tùy chỉnh)")
1350
+ print(f"⚙️ Chế độ: {DEVICE_INFO}")
1351
+ print(f"{'='*60}\n")
 
 
 
 
 
 
 
 
 
 
 
 
1352
 
1353
+ demo.queue().launch(
1354
+ server_name="0.0.0.0",
1355
+ server_port=7860,
1356
+ show_error=True
1357
+ )