Spaces:
Running on Zero
Running on Zero
Upload 9 files
Browse files- .gitattributes +1 -0
- utils/__pycache__/__init__.cpython-312.pyc +0 -0
- utils/__pycache__/core_utils.cpython-312.pyc +0 -0
- utils/__pycache__/normalize_text.cpython-312.pyc +0 -0
- utils/__pycache__/phonemize_text.cpython-312.pyc +0 -0
- utils/core_utils.py +8 -2
- utils/normalize_text.py +33 -34
- utils/phoneme_dict.json +0 -0
- utils/phonemize_text.py +220 -24
.gitattributes
CHANGED
|
@@ -34,3 +34,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
*.wav filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
*.wav filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
utils/phoneme_dict.json filter=lfs diff=lfs merge=lfs -text
|
utils/__pycache__/__init__.cpython-312.pyc
CHANGED
|
Binary files a/utils/__pycache__/__init__.cpython-312.pyc and b/utils/__pycache__/__init__.cpython-312.pyc differ
|
|
|
utils/__pycache__/core_utils.cpython-312.pyc
CHANGED
|
Binary files a/utils/__pycache__/core_utils.cpython-312.pyc and b/utils/__pycache__/core_utils.cpython-312.pyc differ
|
|
|
utils/__pycache__/normalize_text.cpython-312.pyc
CHANGED
|
Binary files a/utils/__pycache__/normalize_text.cpython-312.pyc and b/utils/__pycache__/normalize_text.cpython-312.pyc differ
|
|
|
utils/__pycache__/phonemize_text.cpython-312.pyc
CHANGED
|
Binary files a/utils/__pycache__/phonemize_text.cpython-312.pyc and b/utils/__pycache__/phonemize_text.cpython-312.pyc differ
|
|
|
utils/core_utils.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
import re
|
|
|
|
| 2 |
from typing import List
|
| 3 |
|
| 4 |
def split_text_into_chunks(text: str, max_chars: int = 256) -> List[str]:
|
| 5 |
"""
|
| 6 |
Split raw text into chunks no longer than max_chars.
|
| 7 |
-
Preference is given to sentence boundaries; otherwise falls back to word-based splitting.
|
| 8 |
"""
|
| 9 |
-
sentences = re.split(r"(?<=[\.\!\?\…])\s+", text.strip())
|
| 10 |
chunks: List[str] = []
|
| 11 |
buffer = ""
|
| 12 |
|
|
@@ -45,3 +45,9 @@ def split_text_into_chunks(text: str, max_chars: int = 256) -> List[str]:
|
|
| 45 |
|
| 46 |
flush_buffer()
|
| 47 |
return [chunk for chunk in chunks if chunk]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import re
|
| 2 |
+
import os
|
| 3 |
from typing import List
|
| 4 |
|
| 5 |
def split_text_into_chunks(text: str, max_chars: int = 256) -> List[str]:
|
| 6 |
"""
|
| 7 |
Split raw text into chunks no longer than max_chars.
|
|
|
|
| 8 |
"""
|
| 9 |
+
sentences = re.split(r"(?<=[\.\!\?\…\n])\s+|(?<=\n)", text.strip())
|
| 10 |
chunks: List[str] = []
|
| 11 |
buffer = ""
|
| 12 |
|
|
|
|
| 45 |
|
| 46 |
flush_buffer()
|
| 47 |
return [chunk for chunk in chunks if chunk]
|
| 48 |
+
|
| 49 |
+
def env_bool(name: str, default: bool = False) -> bool:
|
| 50 |
+
v = os.getenv(name)
|
| 51 |
+
if v is None:
|
| 52 |
+
return default
|
| 53 |
+
return v.strip().lower() in ("1", "true", "yes", "y", "on")
|
utils/normalize_text.py
CHANGED
|
@@ -45,7 +45,18 @@ class VietnameseTTSNormalizer:
|
|
| 45 |
'năm', 'sáu', 'bảy', 'tám', 'chín']
|
| 46 |
|
| 47 |
def normalize(self, text):
|
| 48 |
-
"""Main normalization pipeline."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
text = text.lower()
|
| 50 |
text = self._normalize_temperature(text)
|
| 51 |
text = self._normalize_currency(text)
|
|
@@ -58,6 +69,14 @@ class VietnameseTTSNormalizer:
|
|
| 58 |
text = self._number_to_words(text)
|
| 59 |
text = self._normalize_special_chars(text)
|
| 60 |
text = self._normalize_whitespace(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
return text
|
| 62 |
|
| 63 |
def _normalize_temperature(self, text):
|
|
@@ -141,9 +160,8 @@ class VietnameseTTSNormalizer:
|
|
| 141 |
hour, minute, second = groups
|
| 142 |
hour_int, minute_int, second_int = int(hour), int(minute), int(second)
|
| 143 |
|
| 144 |
-
# Validate ranges
|
| 145 |
if not (0 <= hour_int <= 23):
|
| 146 |
-
return match.group(0)
|
| 147 |
if not (0 <= minute_int <= 59):
|
| 148 |
return match.group(0)
|
| 149 |
if not (0 <= second_int <= 59):
|
|
@@ -156,7 +174,6 @@ class VietnameseTTSNormalizer:
|
|
| 156 |
hour, minute = groups
|
| 157 |
hour_int, minute_int = int(hour), int(minute)
|
| 158 |
|
| 159 |
-
# Validate ranges
|
| 160 |
if not (0 <= hour_int <= 23):
|
| 161 |
return match.group(0)
|
| 162 |
if not (0 <= minute_int <= 59):
|
|
@@ -174,7 +191,6 @@ class VietnameseTTSNormalizer:
|
|
| 174 |
|
| 175 |
return f"{hour} giờ"
|
| 176 |
|
| 177 |
-
# Apply patterns with validation
|
| 178 |
text = re.sub(r'(\d{1,2}):(\d{2}):(\d{2})', validate_and_convert_time, text)
|
| 179 |
text = re.sub(r'(\d{1,2}):(\d{2})', validate_and_convert_time, text)
|
| 180 |
text = re.sub(r'(\d{1,2})h(\d{2})', validate_and_convert_time, text)
|
|
@@ -189,7 +205,6 @@ class VietnameseTTSNormalizer:
|
|
| 189 |
"""Check if date components are valid."""
|
| 190 |
day, month, year = int(day), int(month), int(year)
|
| 191 |
|
| 192 |
-
# Basic range checks
|
| 193 |
if not (1 <= day <= 31):
|
| 194 |
return False
|
| 195 |
if not (1 <= month <= 12):
|
|
@@ -201,7 +216,7 @@ class VietnameseTTSNormalizer:
|
|
| 201 |
day, month, year = match.groups()
|
| 202 |
if is_valid_date(day, month, year):
|
| 203 |
return f"ngày {day} tháng {month} năm {year}"
|
| 204 |
-
return match.group(0)
|
| 205 |
|
| 206 |
def date_iso_to_text(match):
|
| 207 |
year, month, day = match.groups()
|
|
@@ -216,7 +231,6 @@ class VietnameseTTSNormalizer:
|
|
| 216 |
return f"ngày {day} tháng {month} năm {full_year}"
|
| 217 |
return match.group(0)
|
| 218 |
|
| 219 |
-
# Apply patterns with validation
|
| 220 |
text = re.sub(r'\bngày\s+(\d{1,2})[/\-](\d{1,2})[/\-](\d{4})\b',
|
| 221 |
lambda m: date_to_text(m).replace('ngày ngày', 'ngày'), text)
|
| 222 |
text = re.sub(r'\bngày\s+(\d{1,2})[/\-](\d{1,2})[/\-](\d{2})\b',
|
|
@@ -248,10 +262,8 @@ class VietnameseTTSNormalizer:
|
|
| 248 |
|
| 249 |
def _normalize_numbers(self, text):
|
| 250 |
text = re.sub(r'(\d+(?:[,.]\d+)?)%', lambda m: f'{m.group(1)} phần trăm', text)
|
| 251 |
-
# 1. Xóa dấu thousand separator trước
|
| 252 |
text = re.sub(r'(\d{1,3})(?:\.(\d{3}))+', lambda m: m.group(0).replace('.', ''), text)
|
| 253 |
|
| 254 |
-
# 2. Chuyển số thập phân thành chữ
|
| 255 |
def decimal_to_words(match):
|
| 256 |
whole = match.group(1)
|
| 257 |
decimal = match.group(2)
|
|
@@ -259,9 +271,7 @@ class VietnameseTTSNormalizer:
|
|
| 259 |
separator = 'phẩy' if ',' in match.group(0) else 'chấm'
|
| 260 |
return f"{whole} {separator} {decimal_words}"
|
| 261 |
|
| 262 |
-
# 2a. Dấu phẩy
|
| 263 |
text = re.sub(r'(\d+),(\d+)', decimal_to_words, text)
|
| 264 |
-
# 2b. Dấu chấm (1-2 chữ số thập phân)
|
| 265 |
text = re.sub(r'(\d+)\.(\d{1,2})\b', decimal_to_words, text)
|
| 266 |
|
| 267 |
return text
|
|
@@ -335,7 +345,9 @@ class VietnameseTTSNormalizer:
|
|
| 335 |
remainder = num % 1000
|
| 336 |
result = f"{self._read_three_digits(thousand)} nghìn"
|
| 337 |
if remainder > 0:
|
| 338 |
-
if remainder <
|
|
|
|
|
|
|
| 339 |
result += f" không trăm {self._read_two_digits(remainder)}"
|
| 340 |
else:
|
| 341 |
result += f" {self._read_three_digits(remainder)}"
|
|
@@ -363,7 +375,7 @@ class VietnameseTTSNormalizer:
|
|
| 363 |
text = re.sub(r'\s+[-–—]+\s+', ' ', text)
|
| 364 |
text = re.sub(r'\.{2,}', ' ', text)
|
| 365 |
text = re.sub(r'\s+\.\s+', ' ', text)
|
| 366 |
-
text = re.sub(r'[^\w\sàáảãạăắằẳẵặâấầẩẫậèéẻẽẹêếềểễệìíỉĩịòóỏõọôốồổỗộơớờởỡợùúủũụưứừửữựỳýỷỹỵđ.,!?;:@%]', ' ', text)
|
| 367 |
return text
|
| 368 |
|
| 369 |
def _normalize_whitespace(self, text):
|
|
@@ -377,32 +389,19 @@ if __name__ == "__main__":
|
|
| 377 |
normalizer = VietnameseTTSNormalizer()
|
| 378 |
|
| 379 |
test_texts = [
|
| 380 |
-
"
|
| 381 |
-
"
|
| 382 |
-
"
|
| 383 |
-
"Nhiệt độ
|
| 384 |
-
"
|
| 385 |
-
"Giá trị tăng 2.5M, đạt 10B",
|
| 386 |
-
"Nhiệt độ -15°C vào mùa đông",
|
| 387 |
-
"Điện áp 220V, công suất 2.5kW, tần số 50Hz",
|
| 388 |
-
"Tôi đi lấy l nước về nhà",
|
| 389 |
-
"Cần 5l nước cho công thức này",
|
| 390 |
-
"Vận tốc ánh sáng 299792km/s",
|
| 391 |
-
"Mật độ dân số 450 người/km2",
|
| 392 |
-
"Công suất 100 W/m2",
|
| 393 |
-
"Hôm nay 2025-01-15",
|
| 394 |
-
"Gọi +84 912 345 678",
|
| 395 |
-
"Nhiệt độ 25°C lúc 14:30:45",
|
| 396 |
-
"Ngày 15/12/25",
|
| 397 |
-
"Giá 3.140.159",
|
| 398 |
]
|
| 399 |
|
| 400 |
print("=" * 80)
|
| 401 |
-
print("VIETNAMESE TTS NORMALIZATION TEST")
|
| 402 |
print("=" * 80)
|
| 403 |
|
| 404 |
for text in test_texts:
|
| 405 |
print(f"\n📝 Input: {text}")
|
| 406 |
normalized = normalizer.normalize(text)
|
| 407 |
print(f"🎵 Output: {normalized}")
|
| 408 |
-
print("-" * 80)
|
|
|
|
| 45 |
'năm', 'sáu', 'bảy', 'tám', 'chín']
|
| 46 |
|
| 47 |
def normalize(self, text):
|
| 48 |
+
"""Main normalization pipeline with EN tag protection."""
|
| 49 |
+
# Step 1: Extract and protect EN tags
|
| 50 |
+
en_contents = []
|
| 51 |
+
placeholder_pattern = "___EN_PLACEHOLDER_{}___ "
|
| 52 |
+
|
| 53 |
+
def extract_en(match):
|
| 54 |
+
en_contents.append(match.group(0))
|
| 55 |
+
return placeholder_pattern.format(len(en_contents) - 1)
|
| 56 |
+
|
| 57 |
+
text = re.sub(r'<en>.*?</en>', extract_en, text, flags=re.IGNORECASE)
|
| 58 |
+
|
| 59 |
+
# Step 2: Normal normalization pipeline
|
| 60 |
text = text.lower()
|
| 61 |
text = self._normalize_temperature(text)
|
| 62 |
text = self._normalize_currency(text)
|
|
|
|
| 69 |
text = self._number_to_words(text)
|
| 70 |
text = self._normalize_special_chars(text)
|
| 71 |
text = self._normalize_whitespace(text)
|
| 72 |
+
|
| 73 |
+
# Step 3: Restore EN tags
|
| 74 |
+
for idx, en_content in enumerate(en_contents):
|
| 75 |
+
text = text.replace(placeholder_pattern.format(idx).lower(), en_content + ' ')
|
| 76 |
+
|
| 77 |
+
# Final whitespace cleanup
|
| 78 |
+
text = self._normalize_whitespace(text)
|
| 79 |
+
|
| 80 |
return text
|
| 81 |
|
| 82 |
def _normalize_temperature(self, text):
|
|
|
|
| 160 |
hour, minute, second = groups
|
| 161 |
hour_int, minute_int, second_int = int(hour), int(minute), int(second)
|
| 162 |
|
|
|
|
| 163 |
if not (0 <= hour_int <= 23):
|
| 164 |
+
return match.group(0)
|
| 165 |
if not (0 <= minute_int <= 59):
|
| 166 |
return match.group(0)
|
| 167 |
if not (0 <= second_int <= 59):
|
|
|
|
| 174 |
hour, minute = groups
|
| 175 |
hour_int, minute_int = int(hour), int(minute)
|
| 176 |
|
|
|
|
| 177 |
if not (0 <= hour_int <= 23):
|
| 178 |
return match.group(0)
|
| 179 |
if not (0 <= minute_int <= 59):
|
|
|
|
| 191 |
|
| 192 |
return f"{hour} giờ"
|
| 193 |
|
|
|
|
| 194 |
text = re.sub(r'(\d{1,2}):(\d{2}):(\d{2})', validate_and_convert_time, text)
|
| 195 |
text = re.sub(r'(\d{1,2}):(\d{2})', validate_and_convert_time, text)
|
| 196 |
text = re.sub(r'(\d{1,2})h(\d{2})', validate_and_convert_time, text)
|
|
|
|
| 205 |
"""Check if date components are valid."""
|
| 206 |
day, month, year = int(day), int(month), int(year)
|
| 207 |
|
|
|
|
| 208 |
if not (1 <= day <= 31):
|
| 209 |
return False
|
| 210 |
if not (1 <= month <= 12):
|
|
|
|
| 216 |
day, month, year = match.groups()
|
| 217 |
if is_valid_date(day, month, year):
|
| 218 |
return f"ngày {day} tháng {month} năm {year}"
|
| 219 |
+
return match.group(0)
|
| 220 |
|
| 221 |
def date_iso_to_text(match):
|
| 222 |
year, month, day = match.groups()
|
|
|
|
| 231 |
return f"ngày {day} tháng {month} năm {full_year}"
|
| 232 |
return match.group(0)
|
| 233 |
|
|
|
|
| 234 |
text = re.sub(r'\bngày\s+(\d{1,2})[/\-](\d{1,2})[/\-](\d{4})\b',
|
| 235 |
lambda m: date_to_text(m).replace('ngày ngày', 'ngày'), text)
|
| 236 |
text = re.sub(r'\bngày\s+(\d{1,2})[/\-](\d{1,2})[/\-](\d{2})\b',
|
|
|
|
| 262 |
|
| 263 |
def _normalize_numbers(self, text):
|
| 264 |
text = re.sub(r'(\d+(?:[,.]\d+)?)%', lambda m: f'{m.group(1)} phần trăm', text)
|
|
|
|
| 265 |
text = re.sub(r'(\d{1,3})(?:\.(\d{3}))+', lambda m: m.group(0).replace('.', ''), text)
|
| 266 |
|
|
|
|
| 267 |
def decimal_to_words(match):
|
| 268 |
whole = match.group(1)
|
| 269 |
decimal = match.group(2)
|
|
|
|
| 271 |
separator = 'phẩy' if ',' in match.group(0) else 'chấm'
|
| 272 |
return f"{whole} {separator} {decimal_words}"
|
| 273 |
|
|
|
|
| 274 |
text = re.sub(r'(\d+),(\d+)', decimal_to_words, text)
|
|
|
|
| 275 |
text = re.sub(r'(\d+)\.(\d{1,2})\b', decimal_to_words, text)
|
| 276 |
|
| 277 |
return text
|
|
|
|
| 345 |
remainder = num % 1000
|
| 346 |
result = f"{self._read_three_digits(thousand)} nghìn"
|
| 347 |
if remainder > 0:
|
| 348 |
+
if remainder < 10:
|
| 349 |
+
result += f" không trăm lẻ {self.digits[remainder]}"
|
| 350 |
+
elif remainder < 100:
|
| 351 |
result += f" không trăm {self._read_two_digits(remainder)}"
|
| 352 |
else:
|
| 353 |
result += f" {self._read_three_digits(remainder)}"
|
|
|
|
| 375 |
text = re.sub(r'\s+[-–—]+\s+', ' ', text)
|
| 376 |
text = re.sub(r'\.{2,}', ' ', text)
|
| 377 |
text = re.sub(r'\s+\.\s+', ' ', text)
|
| 378 |
+
text = re.sub(r'[^\w\sàáảãạăắằẳẵặâấầẩẫậèéẻẽẹêếềểễệìíỉĩịòóỏõọôốồổỗộơớờởỡợùúủũụưứừửữựỳýỷỹỵđ.,!?;:@%_]', ' ', text)
|
| 379 |
return text
|
| 380 |
|
| 381 |
def _normalize_whitespace(self, text):
|
|
|
|
| 389 |
normalizer = VietnameseTTSNormalizer()
|
| 390 |
|
| 391 |
test_texts = [
|
| 392 |
+
"Chào mừng <en>hello world</en> đến với AI",
|
| 393 |
+
"Công nghệ <en>machine learning</en> và <en>deep learning</en>",
|
| 394 |
+
"Giá 2.500.000đ với <en>discount</en> 50%",
|
| 395 |
+
"Nhiệt độ 25°C, <en>temperature</en> cao",
|
| 396 |
+
"Hệ thống <en>text-to-speech</en> tiếng Việt",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
]
|
| 398 |
|
| 399 |
print("=" * 80)
|
| 400 |
+
print("VIETNAMESE TTS NORMALIZATION TEST (WITH EN TAG)")
|
| 401 |
print("=" * 80)
|
| 402 |
|
| 403 |
for text in test_texts:
|
| 404 |
print(f"\n📝 Input: {text}")
|
| 405 |
normalized = normalizer.normalize(text)
|
| 406 |
print(f"🎵 Output: {normalized}")
|
| 407 |
+
print("-" * 80)
|
utils/phoneme_dict.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
utils/phonemize_text.py
CHANGED
|
@@ -2,6 +2,7 @@ import os
|
|
| 2 |
import json
|
| 3 |
import platform
|
| 4 |
import glob
|
|
|
|
| 5 |
from phonemizer import phonemize
|
| 6 |
from phonemizer.backend.espeak.espeak import EspeakWrapper
|
| 7 |
from utils.normalize_text import VietnameseTTSNormalizer
|
|
@@ -106,7 +107,10 @@ except Exception as e:
|
|
| 106 |
raise
|
| 107 |
|
| 108 |
def phonemize_text(text: str) -> str:
|
| 109 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 110 |
text = normalizer.normalize(text)
|
| 111 |
return phonemize(
|
| 112 |
text,
|
|
@@ -117,34 +121,226 @@ def phonemize_text(text: str) -> str:
|
|
| 117 |
language_switch="remove-flags"
|
| 118 |
)
|
| 119 |
|
|
|
|
| 120 |
def phonemize_with_dict(text: str, phoneme_dict=phoneme_dict) -> str:
|
| 121 |
-
"""
|
|
|
|
|
|
|
| 122 |
text = normalizer.normalize(text)
|
| 123 |
-
words = text.split()
|
| 124 |
-
result = []
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
else:
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
|
| 141 |
-
phone_word = 'ɹ' + phone_word[1:]
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
-
result.
|
|
|
|
|
|
|
| 149 |
|
| 150 |
-
return
|
|
|
|
| 2 |
import json
|
| 3 |
import platform
|
| 4 |
import glob
|
| 5 |
+
import re
|
| 6 |
from phonemizer import phonemize
|
| 7 |
from phonemizer.backend.espeak.espeak import EspeakWrapper
|
| 8 |
from utils.normalize_text import VietnameseTTSNormalizer
|
|
|
|
| 107 |
raise
|
| 108 |
|
| 109 |
def phonemize_text(text: str) -> str:
|
| 110 |
+
"""
|
| 111 |
+
Convert text to phonemes (simple version without dict, without EN tag).
|
| 112 |
+
Kept for backward compatibility.
|
| 113 |
+
"""
|
| 114 |
text = normalizer.normalize(text)
|
| 115 |
return phonemize(
|
| 116 |
text,
|
|
|
|
| 121 |
language_switch="remove-flags"
|
| 122 |
)
|
| 123 |
|
| 124 |
+
|
| 125 |
def phonemize_with_dict(text: str, phoneme_dict=phoneme_dict) -> str:
|
| 126 |
+
"""
|
| 127 |
+
Phonemize single text with dictionary lookup and EN tag support.
|
| 128 |
+
"""
|
| 129 |
text = normalizer.normalize(text)
|
|
|
|
|
|
|
| 130 |
|
| 131 |
+
# Split by EN tags
|
| 132 |
+
parts = re.split(r'(<en>.*?</en>)', text, flags=re.IGNORECASE)
|
| 133 |
+
|
| 134 |
+
en_texts = []
|
| 135 |
+
en_indices = []
|
| 136 |
+
vi_texts = []
|
| 137 |
+
vi_indices = []
|
| 138 |
+
vi_word_maps = []
|
| 139 |
+
|
| 140 |
+
processed_parts = []
|
| 141 |
+
|
| 142 |
+
for part_idx, part in enumerate(parts):
|
| 143 |
+
if re.match(r'<en>.*</en>', part, re.IGNORECASE):
|
| 144 |
+
# English part
|
| 145 |
+
en_content = re.sub(r'</?en>', '', part, flags=re.IGNORECASE).strip()
|
| 146 |
+
en_texts.append(en_content)
|
| 147 |
+
en_indices.append(part_idx)
|
| 148 |
+
processed_parts.append(None)
|
| 149 |
else:
|
| 150 |
+
# Vietnamese part
|
| 151 |
+
words = part.split()
|
| 152 |
+
processed_words = []
|
| 153 |
+
|
| 154 |
+
for word_idx, word in enumerate(words):
|
| 155 |
+
match = re.match(r'^(\W*)(.*?)(\W*)$', word)
|
| 156 |
+
pre, core, suf = match.groups() if match else ("", word, "")
|
| 157 |
+
|
| 158 |
+
if not core:
|
| 159 |
+
processed_words.append(word)
|
| 160 |
+
elif core in phoneme_dict:
|
| 161 |
+
processed_words.append(f"{pre}{phoneme_dict[core]}{suf}")
|
| 162 |
+
else:
|
| 163 |
+
vi_texts.append(word)
|
| 164 |
+
vi_indices.append(part_idx)
|
| 165 |
+
vi_word_maps.append((part_idx, len(processed_words)))
|
| 166 |
+
processed_words.append(None)
|
| 167 |
+
|
| 168 |
+
processed_parts.append(processed_words)
|
| 169 |
+
|
| 170 |
+
if en_texts:
|
| 171 |
+
try:
|
| 172 |
+
en_phonemes = phonemize(
|
| 173 |
+
en_texts,
|
| 174 |
+
language='en-us',
|
| 175 |
+
backend='espeak',
|
| 176 |
+
preserve_punctuation=True,
|
| 177 |
+
with_stress=True,
|
| 178 |
+
language_switch="remove-flags"
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
if isinstance(en_phonemes, str):
|
| 182 |
+
en_phonemes = [en_phonemes]
|
| 183 |
+
|
| 184 |
+
for idx, (part_idx, phoneme) in enumerate(zip(en_indices, en_phonemes)):
|
| 185 |
+
processed_parts[part_idx] = phoneme.strip()
|
| 186 |
+
except Exception as e:
|
| 187 |
+
print(f"Warning: Could not phonemize EN texts: {e}")
|
| 188 |
+
for part_idx in en_indices:
|
| 189 |
+
processed_parts[part_idx] = en_texts[en_indices.index(part_idx)]
|
| 190 |
+
|
| 191 |
+
if vi_texts:
|
| 192 |
+
try:
|
| 193 |
+
vi_phonemes = phonemize(
|
| 194 |
+
vi_texts,
|
| 195 |
+
language='vi',
|
| 196 |
+
backend='espeak',
|
| 197 |
+
preserve_punctuation=True,
|
| 198 |
+
with_stress=True,
|
| 199 |
+
language_switch='remove-flags'
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
if isinstance(vi_phonemes, str):
|
| 203 |
+
vi_phonemes = [vi_phonemes]
|
| 204 |
+
|
| 205 |
+
for idx, (part_idx, word_idx) in enumerate(vi_word_maps):
|
| 206 |
+
phoneme = vi_phonemes[idx].strip()
|
| 207 |
+
|
| 208 |
+
original_word = vi_texts[idx]
|
| 209 |
+
if original_word.lower().startswith('r'):
|
| 210 |
+
phoneme = 'ɹ' + phoneme[1:] if len(phoneme) > 0 else phoneme
|
| 211 |
|
| 212 |
+
phoneme_dict[original_word] = phoneme
|
|
|
|
| 213 |
|
| 214 |
+
if processed_parts[part_idx] is not None:
|
| 215 |
+
processed_parts[part_idx][word_idx] = phoneme
|
| 216 |
+
except Exception as e:
|
| 217 |
+
print(f"Warning: Could not phonemize VI texts: {e}")
|
| 218 |
+
for idx, (part_idx, word_idx) in enumerate(vi_word_maps):
|
| 219 |
+
if processed_parts[part_idx] is not None:
|
| 220 |
+
processed_parts[part_idx][word_idx] = vi_texts[idx]
|
| 221 |
+
|
| 222 |
+
final_parts = []
|
| 223 |
+
for part in processed_parts:
|
| 224 |
+
if isinstance(part, list):
|
| 225 |
+
final_parts.append(' '.join(str(w) for w in part if w is not None))
|
| 226 |
+
elif part is not None:
|
| 227 |
+
final_parts.append(part)
|
| 228 |
+
|
| 229 |
+
result = ' '.join(final_parts)
|
| 230 |
+
|
| 231 |
+
result = re.sub(r'\s+([.,!?;:])', r'\1', result)
|
| 232 |
+
|
| 233 |
+
return result
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def phonemize_batch(texts: list, phoneme_dict=phoneme_dict) -> list:
|
| 237 |
+
"""
|
| 238 |
+
Phonemize multiple texts with optimal batching.
|
| 239 |
+
|
| 240 |
+
Args:
|
| 241 |
+
texts: List of text strings to phonemize
|
| 242 |
+
phoneme_dict: Phoneme dictionary for lookup
|
| 243 |
+
|
| 244 |
+
Returns:
|
| 245 |
+
List of phonemized texts
|
| 246 |
+
"""
|
| 247 |
+
normalized_texts = [normalizer.normalize(text) for text in texts]
|
| 248 |
+
|
| 249 |
+
all_en_texts = []
|
| 250 |
+
all_en_maps = []
|
| 251 |
+
|
| 252 |
+
all_vi_texts = []
|
| 253 |
+
all_vi_maps = []
|
| 254 |
+
|
| 255 |
+
results = []
|
| 256 |
+
|
| 257 |
+
for text_idx, text in enumerate(normalized_texts):
|
| 258 |
+
parts = re.split(r'(<en>.*?</en>)', text, flags=re.IGNORECASE)
|
| 259 |
+
processed_parts = []
|
| 260 |
+
|
| 261 |
+
for part_idx, part in enumerate(parts):
|
| 262 |
+
if re.match(r'<en>.*</en>', part, re.IGNORECASE):
|
| 263 |
+
en_content = re.sub(r'</?en>', '', part, flags=re.IGNORECASE).strip()
|
| 264 |
+
all_en_texts.append(en_content)
|
| 265 |
+
all_en_maps.append((text_idx, part_idx))
|
| 266 |
+
processed_parts.append(None)
|
| 267 |
+
else:
|
| 268 |
+
words = part.split()
|
| 269 |
+
processed_words = []
|
| 270 |
+
|
| 271 |
+
for word in words:
|
| 272 |
+
match = re.match(r'^(\W*)(.*?)(\W*)$', word)
|
| 273 |
+
pre, core, suf = match.groups() if match else ("", word, "")
|
| 274 |
+
|
| 275 |
+
if not core:
|
| 276 |
+
processed_words.append(word)
|
| 277 |
+
elif core in phoneme_dict:
|
| 278 |
+
processed_words.append(f"{pre}{phoneme_dict[core]}{suf}")
|
| 279 |
+
else:
|
| 280 |
+
all_vi_texts.append(word)
|
| 281 |
+
all_vi_maps.append((text_idx, part_idx, len(processed_words)))
|
| 282 |
+
processed_words.append(None)
|
| 283 |
+
|
| 284 |
+
processed_parts.append(processed_words)
|
| 285 |
+
|
| 286 |
+
results.append(processed_parts)
|
| 287 |
+
|
| 288 |
+
if all_en_texts:
|
| 289 |
+
try:
|
| 290 |
+
en_phonemes = phonemize(
|
| 291 |
+
all_en_texts,
|
| 292 |
+
language='en-us',
|
| 293 |
+
backend='espeak',
|
| 294 |
+
preserve_punctuation=True,
|
| 295 |
+
with_stress=True,
|
| 296 |
+
language_switch="remove-flags"
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
if isinstance(en_phonemes, str):
|
| 300 |
+
en_phonemes = [en_phonemes]
|
| 301 |
+
|
| 302 |
+
for (text_idx, part_idx), phoneme in zip(all_en_maps, en_phonemes):
|
| 303 |
+
results[text_idx][part_idx] = phoneme.strip()
|
| 304 |
+
except Exception as e:
|
| 305 |
+
print(f"Warning: Batch EN phonemization failed: {e}")
|
| 306 |
+
|
| 307 |
+
if all_vi_texts:
|
| 308 |
+
try:
|
| 309 |
+
vi_phonemes = phonemize(
|
| 310 |
+
all_vi_texts,
|
| 311 |
+
language='vi',
|
| 312 |
+
backend='espeak',
|
| 313 |
+
preserve_punctuation=True,
|
| 314 |
+
with_stress=True,
|
| 315 |
+
language_switch='remove-flags'
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
if isinstance(vi_phonemes, str):
|
| 319 |
+
vi_phonemes = [vi_phonemes]
|
| 320 |
+
|
| 321 |
+
for idx, (text_idx, part_idx, word_idx) in enumerate(all_vi_maps):
|
| 322 |
+
phoneme = vi_phonemes[idx].strip()
|
| 323 |
+
|
| 324 |
+
original_word = all_vi_texts[idx]
|
| 325 |
+
if original_word.lower().startswith('r'):
|
| 326 |
+
phoneme = 'ɹ' + phoneme[1:] if len(phoneme) > 0 else phoneme
|
| 327 |
+
|
| 328 |
+
phoneme_dict[original_word] = phoneme
|
| 329 |
+
results[text_idx][part_idx][word_idx] = phoneme
|
| 330 |
+
except Exception as e:
|
| 331 |
+
print(f"Warning: Batch VI phonemization failed: {e}")
|
| 332 |
+
|
| 333 |
+
final_results = []
|
| 334 |
+
for processed_parts in results:
|
| 335 |
+
final_parts = []
|
| 336 |
+
for part in processed_parts:
|
| 337 |
+
if isinstance(part, list):
|
| 338 |
+
final_parts.append(' '.join(str(w) for w in part if w is not None))
|
| 339 |
+
elif part is not None:
|
| 340 |
+
final_parts.append(part)
|
| 341 |
|
| 342 |
+
result = ' '.join(final_parts)
|
| 343 |
+
result = re.sub(r'\s+([.,!?;:])', r'\1', result)
|
| 344 |
+
final_results.append(result)
|
| 345 |
|
| 346 |
+
return final_results
|