File size: 19,887 Bytes
ecdc85a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df03342
 
 
 
 
 
 
 
 
 
 
ecdc85a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df03342
 
 
 
ecdc85a
 
 
 
 
 
 
df03342
 
 
 
ecdc85a
df03342
 
 
 
 
ecdc85a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df03342
 
 
 
ecdc85a
 
 
 
 
 
 
 
 
 
df03342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ecdc85a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df03342
 
 
ecdc85a
df03342
 
 
ecdc85a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df03342
 
ecdc85a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
"""

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