titusgiap Claude Opus 5 commited on
Commit
35076e5
·
1 Parent(s): 4a400b2

fix(dub): TTS hỏng thì đọc bằng Edge, không trả cả video tiếng lặng

Browse files

Test thật trên Space: /voice-preview trả về ĐÚNG 0.4s im lặng tuyệt đối —
là nhánh _silence(0.4) của synth_line, tức OmniVoice ném lỗi ở MỌI câu.
Hai lỗi lộ ra:

1. synth_line nuốt lỗi thành khoảng lặng và KHÔNG log gì. Hỏng 1 câu thì
không sao, nhưng engine hỏng hệ thống (model chưa tải xong, thiếu RAM)
thì cả video câm mà chẳng báo gì. Nay hỏng -> đọc lại bằng Edge TTS
(online, gần như luôn có); chỉ khi Edge cũng hỏng mới trả khoảng lặng.
2. _ensure_preview cache luôn bản câm đó -> giọng thử câm vĩnh viễn kể cả
sau khi model đã chạy được. Nay phát hiện file câm thì xoá + báo lỗi.

Thêm đường chẩn đoán TỪ XA: ghi lỗi gần nhất của từng engine và trả qua
/api/dub/voices (`last_error`) — trên Space không xem log tiện, mà triệu
chứng thì chỉ là "nghe không thấy gì".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

backend/app/api/dub.py CHANGED
@@ -86,6 +86,15 @@ def _ensure_preview(lang: str, voice: str) -> str:
86
  vcfg = tts_svc.VoiceConfig(target_lang=lang, voice_source="preset", preset=voice)
87
  engine = tts_svc.load_engine(vcfg)
88
  tts_svc.synth_line(engine, text, out, vcfg)
 
 
 
 
 
 
 
 
 
89
  return out
90
 
91
 
 
86
  vcfg = tts_svc.VoiceConfig(target_lang=lang, voice_source="preset", preset=voice)
87
  engine = tts_svc.load_engine(vcfg)
88
  tts_svc.synth_line(engine, text, out, vcfg)
89
+ # KHÔNG cache một bản câm: TTS hỏng thì synth_line trả khoảng lặng, cache
90
+ # lại là giọng thử đó câm vĩnh viễn kể cả sau khi model đã chạy được.
91
+ if tts_svc.is_silent_wav(out):
92
+ err = tts_svc.last_error(tts_svc.pick_engine(vcfg)) or "không rõ nguyên nhân"
93
+ try:
94
+ os.remove(out)
95
+ except OSError:
96
+ pass
97
+ raise RuntimeError(f"TTS trả về tiếng lặng ({err})")
98
  return out
99
 
100
 
backend/app/services/dubber/tts.py CHANGED
@@ -369,6 +369,21 @@ CAN_CLONE = {"vieneu": True, "omni": True, "xtts": True, "edge": False}
369
  # Cache instance để KHÔNG nạp lại model nặng ở mỗi job (VieNeu/XTTS load 1 lần).
370
  _INSTANCES: dict[str, TTSEngine] = {}
371
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
 
373
  def load_engine(cfg: VoiceConfig) -> TTSEngine:
374
  name = pick_engine(cfg)
@@ -383,8 +398,9 @@ def load_engine(cfg: VoiceConfig) -> TTSEngine:
383
  # (online, nhẹ) để vẫn LỒNG ĐƯỢC tiếng thay vì hỏng cả pipeline.
384
  if name == "edge":
385
  raise
 
386
  import sys
387
- print(f"[tts] engine '{name}' không nạp được ({e}); dùng Edge TTS thay.",
388
  file=sys.stderr)
389
  if "edge" not in _INSTANCES:
390
  _INSTANCES["edge"] = EdgeTTSEngine()
@@ -431,7 +447,8 @@ def engine_info(lang: str) -> dict:
431
  name = pick_engine(VoiceConfig(target_lang=lang))
432
  return {"engine": name,
433
  "model_label": MODEL_LABELS.get(name, name),
434
- "can_clone": bool(CAN_CLONE.get(name, False))}
 
435
 
436
 
437
  def list_voices(lang: str, allow_load: bool = False) -> list[dict]:
@@ -454,21 +471,62 @@ def list_voices(lang: str, allow_load: bool = False) -> list[dict]:
454
  for (label, vid) in _static_voices(lang, name)]
455
 
456
 
 
 
 
 
 
 
 
 
 
457
  def synth_line(engine: TTSEngine, text: str, out_wav: str, cfg: VoiceConfig) -> str:
458
- """Tổng hợp 1 câu, chịu lỗi: câu rỗng / model không ghi được -> file lặng ngắn."""
 
 
 
 
 
 
 
459
  if not text.strip():
460
  _silence(out_wav, 0.2)
461
  return out_wav
462
  try:
463
- engine.synth(text, out_wav, cfg)
464
- # Một số wrapper trả về không lỗi nhưng KHÔNG ghi file -> verify để bước sau
465
- # (fit_to_slot) không FileNotFound làm vỡ pipeline.
466
- if not (os.path.exists(out_wav) and os.path.getsize(out_wav) > 44):
467
- raise RuntimeError("engine không ghi ra file wav")
468
- return out_wav
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  except Exception:
470
- _silence(out_wav, 0.4) # lỗi 1 câu -> khoảng lặng, đi tiếp
471
- return out_wav
 
472
 
473
 
474
  def _silence(out_wav: str, dur: float):
 
369
  # Cache instance để KHÔNG nạp lại model nặng ở mỗi job (VieNeu/XTTS load 1 lần).
370
  _INSTANCES: dict[str, TTSEngine] = {}
371
 
372
+ # Lỗi GẦN NHẤT của từng engine. Trên server (HF Space) không xem log tiện, mà TTS
373
+ # hỏng thì chỉ thấy tiếng lặng -> đưa lỗi ra /api/dub/voices để soi được từ xa.
374
+ _LAST_ERROR: dict[str, str] = {}
375
+
376
+
377
+ def _note_error(engine_name: str, exc: BaseException) -> None:
378
+ import sys
379
+ msg = f"{type(exc).__name__}: {exc}"
380
+ _LAST_ERROR[engine_name] = msg[:500]
381
+ print(f"[tts] engine '{engine_name}' lỗi -> {msg[:300]}", file=sys.stderr)
382
+
383
+
384
+ def last_error(engine_name: str = "") -> str:
385
+ return _LAST_ERROR.get(engine_name, "") if engine_name else ""
386
+
387
 
388
  def load_engine(cfg: VoiceConfig) -> TTSEngine:
389
  name = pick_engine(cfg)
 
398
  # (online, nhẹ) để vẫn LỒNG ĐƯỢC tiếng thay vì hỏng cả pipeline.
399
  if name == "edge":
400
  raise
401
+ _note_error(name, e)
402
  import sys
403
+ print(f"[tts] engine '{name}' không nạp được; dùng Edge TTS thay.",
404
  file=sys.stderr)
405
  if "edge" not in _INSTANCES:
406
  _INSTANCES["edge"] = EdgeTTSEngine()
 
447
  name = pick_engine(VoiceConfig(target_lang=lang))
448
  return {"engine": name,
449
  "model_label": MODEL_LABELS.get(name, name),
450
+ "can_clone": bool(CAN_CLONE.get(name, False)),
451
+ "last_error": last_error(name)}
452
 
453
 
454
  def list_voices(lang: str, allow_load: bool = False) -> list[dict]:
 
471
  for (label, vid) in _static_voices(lang, name)]
472
 
473
 
474
+ def _run_engine(engine: TTSEngine, text: str, out_wav: str, cfg: VoiceConfig) -> str:
475
+ engine.synth(text, out_wav, cfg)
476
+ # Một số wrapper trả về không lỗi nhưng KHÔNG ghi file -> verify để bước sau
477
+ # (fit_to_slot) không FileNotFound làm vỡ pipeline.
478
+ if not (os.path.exists(out_wav) and os.path.getsize(out_wav) > 44):
479
+ raise RuntimeError("engine không ghi ra file wav")
480
+ return out_wav
481
+
482
+
483
  def synth_line(engine: TTSEngine, text: str, out_wav: str, cfg: VoiceConfig) -> str:
484
+ """Tổng hợp 1 câu, chịu lỗi theo BA NẤC.
485
+
486
+ Engine chính hỏng -> ĐỌC LẠI BẰNG EDGE TTS chứ KHÔNG trả khoảng lặng ngay.
487
+ Trước đây lỗi là nhét im lặng luôn: hỏng đúng 1 câu thì không sao, nhưng
488
+ engine hỏng HỆ THỐNG (model chưa tải xong, thiếu RAM...) thì mọi câu đều
489
+ lặng -> cả video câm mà chẳng báo gì. Edge chạy online, gần như luôn có.
490
+ Chỉ khi Edge cũng hỏng mới chịu trả khoảng lặng để pipeline đi tiếp.
491
+ """
492
  if not text.strip():
493
  _silence(out_wav, 0.2)
494
  return out_wav
495
  try:
496
+ return _run_engine(engine, text, out_wav, cfg)
497
+ except Exception as e:
498
+ name = pick_engine(cfg)
499
+ _note_error(name, e)
500
+ if not isinstance(engine, EdgeTTSEngine):
501
+ try:
502
+ return _run_engine(_edge_fallback(), text, out_wav, cfg)
503
+ except Exception as e2:
504
+ _note_error("edge", e2)
505
+ _silence(out_wav, 0.4) # hết đường -> khoảng lặng, đi tiếp
506
+ return out_wav
507
+
508
+
509
+ def _edge_fallback() -> TTSEngine:
510
+ """Instance Edge TTS dùng chung để đỡ engine chính lúc hỏng."""
511
+ if "edge" not in _INSTANCES:
512
+ _INSTANCES["edge"] = EdgeTTSEngine()
513
+ return _INSTANCES["edge"]
514
+
515
+
516
+ def is_silent_wav(path: str) -> bool:
517
+ """File WAV có phải LẶNG HOÀN TOÀN không (dấu hiệu TTS hỏng, không phải
518
+ giọng thật). Dùng để không cache nhầm một bản 'giọng thử' câm."""
519
+ import wave
520
+
521
+ try:
522
+ with wave.open(path, "rb") as w:
523
+ if w.getsampwidth() != 2:
524
+ return False # không phải 16-bit -> khỏi đoán
525
+ raw = w.readframes(min(w.getnframes(), 240000))
526
  except Exception:
527
+ return False
528
+ # PCM 16-bit lặng tuyệt đối = mọi byte đều 0.
529
+ return len(raw) > 0 and not any(raw)
530
 
531
 
532
  def _silence(out_wav: str, dur: float):
backend/tests/test_dub.py CHANGED
@@ -362,6 +362,65 @@ def test_omnivoice_clone_gets_ref_text():
362
  restore()
363
 
364
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  def test_edge_ignores_foreign_voice_id():
366
  """Id giọng của engine khác (vd 'omni:en-f-warm' còn kẹt trong state trình
367
  duyệt) không được đưa thẳng cho edge-tts — sẽ lỗi cả câu."""
 
362
  restore()
363
 
364
 
365
+ def test_engine_failure_falls_back_to_edge_not_silence():
366
+ """Engine chính hỏng -> ĐỌC BẰNG EDGE, không được trả cả video tiếng lặng.
367
+
368
+ Đây là bẫy thật gặp trên Space: OmniVoice hỏng, synth_line nhét khoảng lặng
369
+ cho MỌI câu -> video câm mà không báo gì.
370
+ """
371
+ import os
372
+ import tempfile
373
+
374
+ d = tempfile.mkdtemp()
375
+ out = os.path.join(d, "x.wav")
376
+
377
+ class Boom(tts.TTSEngine):
378
+ def synth(self, text, out_wav, cfg):
379
+ raise RuntimeError("model chưa tải xong")
380
+
381
+ used = {}
382
+
383
+ class FakeEdge(tts.EdgeTTSEngine):
384
+ def __init__(self):
385
+ pass
386
+
387
+ def synth(self, text, out_wav, cfg):
388
+ used["text"] = text
389
+ tts._silence(out_wav, 1.0) # coi như đọc ra 1 giây tiếng
390
+ return out_wav
391
+
392
+ old_inst = dict(tts._INSTANCES)
393
+ tts._INSTANCES["edge"] = FakeEdge()
394
+ try:
395
+ cfg = tts.VoiceConfig(target_lang="en", engine="omni", preset="omni:en-f-warm")
396
+ tts.synth_line(Boom(), "Hello there", out, cfg)
397
+ assert used.get("text") == "Hello there" # đã nhờ Edge đọc lại
398
+ assert os.path.getsize(out) > 44
399
+ assert "model chưa tải xong" in tts.last_error("omni") # có ghi lỗi để soi
400
+ finally:
401
+ tts._INSTANCES.clear()
402
+ tts._INSTANCES.update(old_inst)
403
+ tts._LAST_ERROR.clear()
404
+
405
+
406
+ def test_is_silent_wav_detects_dead_tts():
407
+ """Nhận ra file câm để KHÔNG cache nhầm thành 'giọng thử'."""
408
+ import os
409
+ import tempfile
410
+
411
+ d = tempfile.mkdtemp()
412
+ quiet = os.path.join(d, "q.wav")
413
+ tts._silence(quiet, 0.4)
414
+ assert tts.is_silent_wav(quiet)
415
+ loud = make_video(os.path.join(d, "v.mp4"), dur=1.0, with_audio=True)
416
+ wav = os.path.join(d, "loud.wav")
417
+ import subprocess
418
+ subprocess.run([tts.FFMPEG, "-y", "-hide_banner", "-loglevel", "error",
419
+ "-i", loud, "-ar", "24000", "-ac", "1", wav],
420
+ check=True, capture_output=True)
421
+ assert not tts.is_silent_wav(wav)
422
+
423
+
424
  def test_edge_ignores_foreign_voice_id():
425
  """Id giọng của engine khác (vd 'omni:en-f-warm' còn kẹt trong state trình
426
  duyệt) không được đưa thẳng cho edge-tts — sẽ lỗi cả câu."""