Spaces:
Running
fix(dub): TTS hỏng thì đọc bằng Edge, không trả cả video tiếng lặng
Browse filesTest 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 +9 -0
- backend/app/services/dubber/tts.py +69 -11
- backend/tests/test_dub.py +59 -0
|
@@ -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 |
|
|
@@ -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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
if not text.strip():
|
| 460 |
_silence(out_wav, 0.2)
|
| 461 |
return out_wav
|
| 462 |
try:
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
except Exception:
|
| 470 |
-
|
| 471 |
-
|
|
|
|
| 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):
|
|
@@ -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."""
|