DRIPPY4 / app /core /cloud_tts.py
hoangtaiii's picture
Fix 2p->20s + 20 bugs: pad audio, OCR/ASR coverage gates, TTS cache/placeholder, omni cloud, ASS escape, timeout, preflight, pool locks, SSRF guards
df03342 verified
Raw History Blame Contribute Delete
19.9 kB
"""
app/core/cloud_tts.py
──────────────────────
High-Performance Parallel Cloud TTS Module using Microsoft Edge-TTS.
Features:
1. Concurrent synthesis with asyncio.gather + Semaphore(8).
2. Vietnamese text normalization (numbers, dates, currency, abbreviations).
3. Dynamic Audio Time-Stretching (FFmpeg atempo): Auto-speeds up sentences if TTS duration exceeds timeline slot (up to 1.65x).
4. Full Audio Integrity: NEVER hard-slices or cuts off ending syllables/words.
5. Exact millisecond timeline synchronization.
"""
import os
import re
import json
import time
import asyncio
import subprocess
from pathlib import Path
from typing import List, Dict, Optional, Callable
from app.core.vietnamese_text_normalizer import VietnameseTextNormalizer
# ── OmniVoice presets — sync với D:\omnivoice - web\app.py ──
OMNIVOICE_PRESETS = {
"fun_male": "male, young adult, high pitch",
"story_male": "male, young adult, moderate pitch",
"meme_male": "male, young adult, moderate pitch",
"calm_fun_male": "male, young adult, low pitch",
}
OMNIVOICE_EXTENDED = {"meme_male_gasp", "meme_male_normal", "meme_male_excited", "meme_male_confident",
"fun_male_gasp", "story_male_normal", "calm_fun_male_sad", "calm_fun_male_whispering",
"meme_male_sad", "meme_male_whispering", "fun_male_excited"}
OMNIVOICE_ALL = set(OMNIVOICE_PRESETS.keys()) | OMNIVOICE_EXTENDED
def _is_omnivoice_voice(v: str) -> bool:
v = str(v or "").strip()
if v in OMNIVOICE_ALL:
return True
for p in OMNIVOICE_PRESETS:
if v.startswith(p):
return True
return False
def _resolve_omnivoice_preset_cloud(voice: str) -> str:
v = str(voice or "").strip()
if v in OMNIVOICE_PRESETS:
return v
for p in OMNIVOICE_PRESETS:
if v.startswith(p):
return p
return "meme_male"
# Placeholder dịch fail — TTS phải SKIP chứ không đọc lên thành tiếng
TTS_PLACEHOLDER_NORMED = {"CẦN DỊCH LẠI", "CAN DICH LAI", "DỊCH LẠI", "CẦN DỊCH"}
def _is_tts_placeholder(text: str) -> bool:
import re as _re
t = str(text or "").strip()
if not t:
return False
norm = _re.sub(r"\s+", " ", t).strip().strip("[]").upper()
return norm in TTS_PLACEHOLDER_NORMED
class CloudTTSEngine:
def __init__(self, log_fn: Optional[Callable[[str], None]] = None, ffmpeg_path: str = "ffmpeg"):
self.log_fn = log_fn or print
self.ffmpeg_path = ffmpeg_path
self.normalizer = VietnameseTextNormalizer()
# OmniVoice Cloud config — đọc từ config.json hoặc env
self.omnivoice_api_url = os.getenv("OMNIVOICE_API_URL", "https://hoangtaiii-omnivoice.hf.space").rstrip("/")
self.omnivoice_api_key = os.getenv("OMNIVOICE_API_KEY", "sk-demo123")
# HF token cho ZeroGPU quota (https://huggingface.co/settings/tokens) — ưu tiên env HF_TOKEN
self.hf_token = os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN") or os.getenv("HUGGINGFACE_TOKEN") or ""
# Thử đọc config.json nếu có
try:
cfg_path = Path(__file__).resolve().parents[2] / "config.json"
if cfg_path.exists():
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
self.hf_token = self.hf_token or cfg.get("hf_key", "") or cfg.get("hf_token", "")
tts_cfg = cfg.get("tts", {})
self.omnivoice_api_url = (tts_cfg.get("omnivoice_api_url") or tts_cfg.get("omnivoice_cloud_url") or self.omnivoice_api_url).rstrip("/")
self.omnivoice_api_key = tts_cfg.get("omnivoice_api_key") or tts_cfg.get("omnivoice_cloud_api_key") or self.omnivoice_api_key
# ưu tiên token trong tts config nếu có
self.hf_token = self.hf_token or tts_cfg.get("hf_token", "") or tts_cfg.get("hf_key", "")
except Exception:
pass
def _log(self, msg: str):
self.log_fn(f"[Cloud TTS] {msg}")
def synthesize_srt_to_audio(
self,
srt_path: str,
output_wav: str,
voice: str = "vi-VN-NamMinhNeural",
speed: float = 1.0,
pitch: int = 0,
volume: int = 100,
temp_dir: Optional[str] = None
) -> bool:
srt_file = Path(srt_path)
if not srt_file.exists():
self._log(f"❌ SRT file not found: {srt_path}")
return False
out_file = Path(output_wav)
out_file.parent.mkdir(parents=True, exist_ok=True)
segments_dir = Path(temp_dir or (out_file.parent / "tts_segments"))
segments_dir.mkdir(parents=True, exist_ok=True)
blocks = self._parse_srt(srt_file)
if not blocks:
self._log("⚠️ Không có block phụ đề nào để tổng hợp TTS.")
return False
# ── Auto-dispatch: OmniVoice Cloud nếu voice là preset mới ──
is_ov = _is_omnivoice_voice(voice)
if is_ov:
self._log(f"🎙️ Phát hiện giọng OmniVoice Cloud: {voice} -> {self.omnivoice_api_url}")
try:
import requests # check sẵn
except ImportError:
self._log("❌ requests chưa cài — pip install requests")
return False
self._log(f"⚡ Tạo giọng đọc OmniVoice Cloud song song cho {len(blocks)} câu ({voice})...")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
success = loop.run_until_complete(
self._synthesize_blocks_parallel_omnivoice(blocks, segments_dir, voice, speed)
)
finally:
loop.close()
else:
self._log(f"⚡ Tạo giọng đọc song song cho {len(blocks)} câu thoại ({voice})...")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
success = loop.run_until_complete(
self._synthesize_blocks_parallel(blocks, segments_dir, voice, speed, pitch, volume)
)
finally:
loop.close()
if not success:
self._log("❌ Lỗi tổng hợp giọng đọc TTS.")
return False
self._log("🎚️ Đang tự động co dãn tốc độ (Time-Stretch) & giữ trọn vẹn 100% âm thanh cuối câu...")
return self._merge_segments_to_timeline(blocks, segments_dir, out_file)
async def _synthesize_blocks_parallel(
self,
blocks: List[Dict],
segments_dir: Path,
voice: str,
speed: float,
pitch: int,
volume: int
) -> bool:
import edge_tts
rate_str = f"{int(round((speed - 1.0) * 100)):+d}%"
pitch_str = f"{int(round(pitch)):+d}Hz"
vol_str = f"{int(round(volume - 100)):+d}%"
semaphore = asyncio.Semaphore(8)
async def _synthesize_one(block: Dict):
raw_text = block["text"].strip()
if not raw_text:
return
# FIX: placeholder "[CẦN DỊCH LẠI]" mà đọc lên là bug P0 — skip im lặng
if _is_tts_placeholder(raw_text):
self._log(f"⏭️ Skip block {block['id']}: placeholder dịch fail, để im lặng.")
return
# Apply Vietnamese normalizer
text = self.normalizer.normalize(raw_text) if hasattr(self.normalizer, "normalize") else raw_text
text = self._sanitize_for_tts(text)
seg_path = segments_dir / f"seg_{block['id']:04d}.mp3"
wav_path = segments_dir / f"seg_{block['id']:04d}.wav"
sidecar_path = segments_dir / f"seg_{block['id']:04d}.txt"
# FIX stale cache: trước đây chỉ check wav tồn tại -> sửa sub không có tác dụng.
# Nay so text sidecar, khác text là tổng hợp lại.
if wav_path.exists() and wav_path.stat().st_size > 500:
try:
if sidecar_path.exists() and sidecar_path.read_text(encoding="utf-8") == text:
return
except Exception:
pass
async with semaphore:
for attempt in range(3):
try:
communicate = edge_tts.Communicate(
text,
voice=voice,
rate=rate_str,
pitch=pitch_str,
volume=vol_str
)
await communicate.save(str(seg_path))
# Convert to standard 16kHz mono WAV with gentle silence padding at end to prevent clipping
cmd = [
str(self.ffmpeg_path), "-y",
"-i", str(seg_path),
"-af", "apad=pad_dur=0.08",
"-ar", "16000", "-ac", "1",
str(wav_path)
]
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
if seg_path.exists():
seg_path.unlink()
try:
sidecar_path.write_text(text, encoding="utf-8")
except Exception:
pass
break
except Exception as e:
if attempt == 2:
self._log(f"⚠️ Thất bại block {block['id']}: {e}")
await asyncio.sleep(0.5)
tasks = [_synthesize_one(b) for b in blocks]
await asyncio.gather(*tasks)
return True
async def _synthesize_blocks_parallel_omnivoice(
self,
blocks: List[Dict],
segments_dir: Path,
voice: str,
speed: float,
) -> bool:
"""FIX P0: hàm này được gọi ở synthesize_srt_to_audio nhưng chưa từng tồn tại
-> mọi giọng OmniVoice trên cloud path rớt AttributeError -> job FAILED.
Dùng FastAPI /v1/generate (thread-safe qua asyncio.to_thread), semaphore 4."""
import requests as _requests
try:
speed_val = max(0.5, min(1.5, float(speed)))
except Exception:
speed_val = 1.08
preset = _resolve_omnivoice_preset_cloud(voice)
headers = {"X-API-Key": self.omnivoice_api_key, "Content-Type": "application/json"}
semaphore = asyncio.Semaphore(4)
def _post_one(payload: dict) -> bytes:
resp = _requests.post(
f"{self.omnivoice_api_url}/v1/generate",
headers=headers, json=payload, timeout=240)
if resp.status_code != 200:
raise RuntimeError(f"OmniVoice Cloud {resp.status_code}: {resp.text[:300]}")
ctype = resp.headers.get("content-type", "")
if "audio" not in ctype and len(resp.content) < 1000:
raise RuntimeError(f"OmniVoice Cloud non-audio: {resp.content[:300]!r}")
return resp.content
async def _synthesize_one_ov(block: Dict):
raw_text = str(block.get("text", "") or "").strip()
if not raw_text or _is_tts_placeholder(raw_text):
return
text = self.normalizer.normalize(raw_text) if hasattr(self.normalizer, "normalize") else raw_text
text = self._sanitize_for_tts(text)
if not text:
return
wav_path = segments_dir / f"seg_{block['id']:04d}.wav"
sidecar_path = segments_dir / f"seg_{block['id']:04d}.txt"
if wav_path.exists() and wav_path.stat().st_size > 500:
try:
if sidecar_path.exists() and sidecar_path.read_text(encoding="utf-8") == text:
return
except Exception:
pass
payload = {"text": text, "preset": preset, "speed": float(speed_val),
"steps": 16, "guidance": 2.0, "language": "vi"}
async with semaphore:
for attempt in range(3):
try:
audio_bytes = await asyncio.to_thread(_post_one, payload)
tmp_raw = segments_dir / f"seg_{block['id']:04d}.raw"
tmp_raw.write_bytes(audio_bytes)
cmd = [str(self.ffmpeg_path), "-y", "-i", str(tmp_raw),
"-af", "apad=pad_dur=0.08",
"-ar", "16000", "-ac", "1", str(wav_path)]
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
try:
tmp_raw.unlink()
except Exception:
pass
try:
sidecar_path.write_text(text, encoding="utf-8")
except Exception:
pass
break
except Exception as e:
if attempt == 2:
self._log(f"⚠️ OmniVoice block {block['id']} thất bại: {e}")
await asyncio.sleep(1.0)
await asyncio.gather(*[_synthesize_one_ov(b) for b in blocks])
return True
def _merge_segments_to_timeline(self, blocks: List[Dict], segments_dir: Path, output_wav: Path) -> bool:
try:
from pydub import AudioSegment
except ImportError:
self._log("❌ pydub not installed.")
return False
if not blocks:
return False
sorted_blocks = sorted(blocks, key=lambda x: x["start_ms"])
total_duration_ms = max(b["end_ms"] for b in sorted_blocks) + 10000
canvas = AudioSegment.silent(duration=total_duration_ms, frame_rate=16000)
for i, b in enumerate(sorted_blocks):
wav_path = segments_dir / f"seg_{b['id']:04d}.wav"
if not wav_path.exists() or wav_path.stat().st_size < 300:
continue
try:
seg_audio = AudioSegment.from_wav(str(wav_path))
actual_duration_ms = len(seg_audio)
target_duration_ms = max(300, b["end_ms"] - b["start_ms"])
if i + 1 < len(sorted_blocks):
next_start_ms = sorted_blocks[i + 1]["start_ms"]
gap_to_next = next_start_ms - b["start_ms"]
if gap_to_next > 200:
max_allowed_ms = gap_to_next
else:
max_allowed_ms = target_duration_ms
else:
max_allowed_ms = target_duration_ms + 2500
safety_margin_ms = 80 # Khoảng đệm an toàn giữa 2 câu
max_allowed_ms = max(150, max_allowed_ms - safety_margin_ms)
# Dynamic Time-Stretching (atempo) nếu TTS câu thoại dài hơn khung thời gian
if actual_duration_ms > max_allowed_ms:
speed_ratio = actual_duration_ms / float(max_allowed_ms)
effective_ratio = min(speed_ratio, 2.5)
atempo_filters = []
rem = effective_ratio
while rem > 2.0:
atempo_filters.append("atempo=2.0")
rem /= 2.0
if rem > 1.01:
atempo_filters.append(f"atempo={rem:.3f}")
if atempo_filters:
filter_chain = ",".join(atempo_filters)
stretched_wav = segments_dir / f"seg_{b['id']:04d}_stretched.wav"
cmd_stretch = [
str(self.ffmpeg_path), "-y",
"-i", str(wav_path),
"-filter:a", filter_chain,
"-ar", "16000", "-ac", "1",
str(stretched_wav)
]
res = subprocess.run(cmd_stretch, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if res.returncode == 0 and stretched_wav.exists() and stretched_wav.stat().st_size > 300:
seg_audio = AudioSegment.from_wav(str(stretched_wav))
# FIX: KHÔNG cắt cứng đuôi (trái docstring "NEVER hard-slices").
# Trước đây câu dài hơn slot bị xén chữ im lặng. Nay giữ trọn audio,
# chỉ log cảnh báo để QA loop / người dùng rút gọn text.
if len(seg_audio) > max_allowed_ms:
over_ms = len(seg_audio) - int(max_allowed_ms)
self._log(f"⚠️ Block {b['id']} TTS tràn slot {over_ms}ms sau stretch "
f"({len(seg_audio)}>{int(max_allowed_ms)}) — giữ trọn, có thể lấn nhẹ câu kế.")
fade_ms = min(50, len(seg_audio))
seg_audio = seg_audio.fade_out(fade_ms)
# Overlay segment lên timeline đúng vị trí start_ms
canvas = canvas.overlay(seg_audio, position=b["start_ms"])
except Exception as e:
self._log(f"⚠️ Error overlaying block {b['id']}: {e}")
canvas.export(str(output_wav), format="wav")
return True
def _sanitize_for_tts(self, text: str) -> str:
replacements = {
"\u2011": "-",
"\u2010": "-",
"\u2013": "-",
"\u2014": "-",
"\u200b": "",
"\ufeff": "",
}
for src, dst in replacements.items():
text = text.replace(src, dst)
return text.strip()
def _parse_srt(self, srt_path: Path) -> List[Dict]:
content = srt_path.read_text(encoding="utf-8", errors="ignore")
# FIX: giờ cho phép 1 chữ số (\d{1,2}) — Whisper/tool lẻ ra "0:00:01,000" bị miss cả block
pattern = r"(\d+)\s+(\d{1,2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{1,2}:\d{2}:\d{2}[.,]\d{3})\s*\n(.*?)(?=\n\s*\d+\s+\d{1,2}:\d{2}:\d{2}[.,]\d{3}\s*-->|\Z)"
blocks = []
for m in re.finditer(pattern, content, re.DOTALL):
b_id = int(m.group(1))
start_str = m.group(2).strip().replace(".", ",")
end_str = m.group(3).strip().replace(".", ",")
text = " ".join(line.strip() for line in m.group(4).splitlines() if line.strip())
if text:
blocks.append({
"id": b_id,
"start_str": start_str,
"end_str": end_str,
"start_ms": self._ts_to_ms(start_str),
"end_ms": self._ts_to_ms(end_str),
"text": text
})
return blocks
def _ts_to_ms(self, ts: str) -> int:
ts = ts.strip().replace(".", ",")
m = re.match(r"(\d+):(\d+):(\d+)[,](\d+)", ts)
if m:
h, mins, s, ms = map(int, m.groups())
return ((h * 3600 + mins * 60 + s) * 1000) + ms
return 0