|
|
|
|
|
""" |
|
|
Vietnamese Text Processor for TTS |
|
|
Handles normalization of numbers, dates, times, currencies, etc. |
|
|
""" |
|
|
|
|
|
import re |
|
|
import unicodedata |
|
|
|
|
|
|
|
|
|
|
|
DIGITS = { |
|
|
'0': 'không', '1': 'một', '2': 'hai', '3': 'ba', '4': 'bốn', |
|
|
'5': 'năm', '6': 'sáu', '7': 'bảy', '8': 'tám', '9': 'chín' |
|
|
} |
|
|
|
|
|
TEENS = { |
|
|
'10': 'mười', '11': 'mười một', '12': 'mười hai', '13': 'mười ba', |
|
|
'14': 'mười bốn', '15': 'mười lăm', '16': 'mười sáu', '17': 'mười bảy', |
|
|
'18': 'mười tám', '19': 'mười chín' |
|
|
} |
|
|
|
|
|
TENS = { |
|
|
'2': 'hai mươi', '3': 'ba mươi', '4': 'bốn mươi', '5': 'năm mươi', |
|
|
'6': 'sáu mươi', '7': 'bảy mươi', '8': 'tám mươi', '9': 'chín mươi' |
|
|
} |
|
|
|
|
|
|
|
|
def number_to_words(num_str): |
|
|
""" |
|
|
Convert a number string to Vietnamese words. |
|
|
Handles numbers from 0 to billions. |
|
|
""" |
|
|
|
|
|
num_str = num_str.lstrip('0') or '0' |
|
|
|
|
|
|
|
|
if num_str.startswith('-'): |
|
|
return 'âm ' + number_to_words(num_str[1:]) |
|
|
|
|
|
|
|
|
try: |
|
|
num = int(num_str) |
|
|
except ValueError: |
|
|
return num_str |
|
|
|
|
|
if num == 0: |
|
|
return 'không' |
|
|
|
|
|
if num < 10: |
|
|
return DIGITS[str(num)] |
|
|
|
|
|
if num < 20: |
|
|
return TEENS[str(num)] |
|
|
|
|
|
if num < 100: |
|
|
tens = num // 10 |
|
|
units = num % 10 |
|
|
if units == 0: |
|
|
return TENS[str(tens)] |
|
|
elif units == 1: |
|
|
return TENS[str(tens)] + ' mốt' |
|
|
elif units == 4: |
|
|
return TENS[str(tens)] + ' tư' |
|
|
elif units == 5: |
|
|
return TENS[str(tens)] + ' lăm' |
|
|
else: |
|
|
return TENS[str(tens)] + ' ' + DIGITS[str(units)] |
|
|
|
|
|
if num < 1000: |
|
|
hundreds = num // 100 |
|
|
remainder = num % 100 |
|
|
result = DIGITS[str(hundreds)] + ' trăm' |
|
|
if remainder == 0: |
|
|
return result |
|
|
elif remainder < 10: |
|
|
return result + ' lẻ ' + DIGITS[str(remainder)] |
|
|
else: |
|
|
return result + ' ' + number_to_words(str(remainder)) |
|
|
|
|
|
if num < 1000000: |
|
|
thousands = num // 1000 |
|
|
remainder = num % 1000 |
|
|
result = number_to_words(str(thousands)) + ' nghìn' |
|
|
if remainder == 0: |
|
|
return result |
|
|
elif remainder < 100: |
|
|
return result + ' không trăm ' + number_to_words(str(remainder)) |
|
|
else: |
|
|
return result + ' ' + number_to_words(str(remainder)) |
|
|
|
|
|
if num < 1000000000: |
|
|
millions = num // 1000000 |
|
|
remainder = num % 1000000 |
|
|
result = number_to_words(str(millions)) + ' triệu' |
|
|
if remainder == 0: |
|
|
return result |
|
|
else: |
|
|
return result + ' ' + number_to_words(str(remainder)) |
|
|
|
|
|
if num < 1000000000000: |
|
|
billions = num // 1000000000 |
|
|
remainder = num % 1000000000 |
|
|
result = number_to_words(str(billions)) + ' tỷ' |
|
|
if remainder == 0: |
|
|
return result |
|
|
else: |
|
|
return result + ' ' + number_to_words(str(remainder)) |
|
|
|
|
|
|
|
|
return ' '.join(DIGITS.get(d, d) for d in num_str) |
|
|
|
|
|
|
|
|
def convert_decimal(text): |
|
|
"""Convert decimal numbers: 3.14 -> ba phẩy mười bốn""" |
|
|
def replace_decimal(match): |
|
|
integer_part = match.group(1) |
|
|
decimal_part = match.group(2) |
|
|
|
|
|
integer_words = number_to_words(integer_part) |
|
|
|
|
|
|
|
|
decimal_words = number_to_words(decimal_part.lstrip('0') or '0') |
|
|
|
|
|
return f"{integer_words} phẩy {decimal_words}" |
|
|
|
|
|
|
|
|
|
|
|
text = re.sub(r'(\d+)\.(\d{1,2})(?=\s|$|[^\d])', replace_decimal, text) |
|
|
return text |
|
|
|
|
|
|
|
|
def convert_percentage(text): |
|
|
"""Convert percentages: 50% -> năm mươi phần trăm""" |
|
|
def replace_percent(match): |
|
|
num = match.group(1) |
|
|
return number_to_words(num) + ' phần trăm' |
|
|
|
|
|
text = re.sub(r'(\d+(?:[.,]\d+)?)\s*%', replace_percent, text) |
|
|
return text |
|
|
|
|
|
|
|
|
def convert_currency(text): |
|
|
"""Convert currency amounts""" |
|
|
|
|
|
def replace_vnd(match): |
|
|
num = match.group(1).replace('.', '').replace(',', '') |
|
|
return number_to_words(num) + ' đồng' |
|
|
|
|
|
|
|
|
text = re.sub(r'(\d+(?:[.,]\d+)*)\s*(?:đồng|VND|vnđ)\b', replace_vnd, text, flags=re.IGNORECASE) |
|
|
text = re.sub(r'(\d+(?:[.,]\d+)*)đ(?![a-zà-ỹ])', replace_vnd, text, flags=re.IGNORECASE) |
|
|
|
|
|
|
|
|
def replace_usd(match): |
|
|
num = match.group(1).replace('.', '').replace(',', '') |
|
|
return number_to_words(num) + ' đô la' |
|
|
|
|
|
text = re.sub(r'\$\s*(\d+(?:[.,]\d+)*)', replace_usd, text) |
|
|
text = re.sub(r'(\d+(?:[.,]\d+)*)\s*(?:USD|\$)', replace_usd, text, flags=re.IGNORECASE) |
|
|
|
|
|
return text |
|
|
|
|
|
|
|
|
def convert_time(text): |
|
|
"""Convert time expressions: 2 giờ 20 phút -> hai giờ hai mươi phút""" |
|
|
def replace_time(match): |
|
|
hour = match.group(1) |
|
|
minute = match.group(2) if match.group(2) else None |
|
|
second = match.group(3) if len(match.groups()) > 2 and match.group(3) else None |
|
|
|
|
|
result = number_to_words(hour) + ' giờ' |
|
|
if minute: |
|
|
result += ' ' + number_to_words(minute) + ' phút' |
|
|
if second: |
|
|
result += ' ' + number_to_words(second) + ' giây' |
|
|
return result |
|
|
|
|
|
|
|
|
text = re.sub(r'(\d{1,2}):(\d{2})(?::(\d{2}))?', replace_time, text) |
|
|
|
|
|
|
|
|
def replace_time_vn(match): |
|
|
hour = match.group(1) |
|
|
minute = match.group(2) |
|
|
return number_to_words(hour) + ' giờ ' + number_to_words(minute) + ' phút' |
|
|
|
|
|
text = re.sub(r'(\d+)\s*giờ\s*(\d+)\s*phút', replace_time_vn, text) |
|
|
|
|
|
|
|
|
def replace_hour(match): |
|
|
hour = match.group(1) |
|
|
return number_to_words(hour) + ' giờ' |
|
|
|
|
|
text = re.sub(r'(\d+)\s*giờ(?!\s*\d)', replace_hour, text) |
|
|
|
|
|
return text |
|
|
|
|
|
|
|
|
def convert_date(text): |
|
|
"""Convert date expressions""" |
|
|
|
|
|
def replace_date_full(match): |
|
|
day = match.group(1) |
|
|
month = match.group(2) |
|
|
year = match.group(3) |
|
|
return f"ngày {number_to_words(day)} tháng {number_to_words(month)} năm {number_to_words(year)}" |
|
|
|
|
|
|
|
|
text = re.sub(r'(Sinh|sinh)\s+ngày\s+(\d{1,2})[/-](\d{1,2})[/-](\d{4})', |
|
|
lambda m: f"{m.group(1)} ngày {number_to_words(m.group(2))} tháng {number_to_words(m.group(3))} năm {number_to_words(m.group(4))}", text) |
|
|
|
|
|
text = re.sub(r'(\d{1,2})[/-](\d{1,2})[/-](\d{4})', replace_date_full, text) |
|
|
|
|
|
|
|
|
def replace_month_day(match): |
|
|
day = match.group(1) |
|
|
month = match.group(2) |
|
|
return f"ngày {number_to_words(day)} tháng {number_to_words(month)}" |
|
|
|
|
|
text = re.sub(r'(\d+)\s*tháng\s*(\d+)', replace_month_day, text) |
|
|
|
|
|
|
|
|
def replace_month(match): |
|
|
month = match.group(1) |
|
|
return 'tháng ' + number_to_words(month) |
|
|
|
|
|
text = re.sub(r'tháng\s*(\d+)', replace_month, text) |
|
|
|
|
|
|
|
|
def replace_day(match): |
|
|
day = match.group(1) |
|
|
return 'ngày ' + number_to_words(day) |
|
|
|
|
|
text = re.sub(r'ngày\s*(\d+)', replace_day, text) |
|
|
|
|
|
return text |
|
|
|
|
|
|
|
|
def convert_year_range(text): |
|
|
"""Convert year ranges: 1873-1907 -> một nghìn tám trăm bảy mươi ba đến một nghìn chín trăm lẻ bảy""" |
|
|
def replace_year_range(match): |
|
|
year1 = match.group(1) |
|
|
year2 = match.group(2) |
|
|
return number_to_words(year1) + ' đến ' + number_to_words(year2) |
|
|
|
|
|
text = re.sub(r'(\d{4})\s*[-–—]\s*(\d{4})', replace_year_range, text) |
|
|
return text |
|
|
|
|
|
|
|
|
def convert_ordinal(text): |
|
|
"""Convert ordinals: thứ 2 -> thứ hai""" |
|
|
ordinal_map = { |
|
|
'1': 'nhất', '2': 'hai', '3': 'ba', '4': 'tư', '5': 'năm', |
|
|
'6': 'sáu', '7': 'bảy', '8': 'tám', '9': 'chín', '10': 'mười' |
|
|
} |
|
|
|
|
|
def replace_ordinal(match): |
|
|
prefix = match.group(1) |
|
|
num = match.group(2) |
|
|
if num in ordinal_map: |
|
|
return prefix + ' ' + ordinal_map[num] |
|
|
return prefix + ' ' + number_to_words(num) |
|
|
|
|
|
|
|
|
text = re.sub(r'(thứ|lần|bước|phần|chương|tập|số)\s*(\d+)', replace_ordinal, text, flags=re.IGNORECASE) |
|
|
|
|
|
return text |
|
|
|
|
|
|
|
|
def convert_standalone_numbers(text): |
|
|
"""Convert remaining standalone numbers to words""" |
|
|
def replace_num(match): |
|
|
num = match.group(0) |
|
|
|
|
|
return number_to_words(num) |
|
|
|
|
|
|
|
|
text = re.sub(r'\b\d+\b', replace_num, text) |
|
|
return text |
|
|
|
|
|
|
|
|
def convert_phone_number(text): |
|
|
"""Read phone numbers digit by digit""" |
|
|
def replace_phone(match): |
|
|
phone = match.group(0) |
|
|
digits = re.findall(r'\d', phone) |
|
|
return ' '.join(DIGITS.get(d, d) for d in digits) |
|
|
|
|
|
|
|
|
text = re.sub(r'0\d{9,10}', replace_phone, text) |
|
|
text = re.sub(r'\+84\d{9,10}', replace_phone, text) |
|
|
|
|
|
return text |
|
|
|
|
|
|
|
|
def normalize_unicode(text): |
|
|
"""Normalize Unicode to NFC form""" |
|
|
return unicodedata.normalize('NFC', text) |
|
|
|
|
|
|
|
|
def clean_whitespace(text): |
|
|
"""Clean up extra whitespace""" |
|
|
text = re.sub(r'\s+', ' ', text) |
|
|
return text.strip() |
|
|
|
|
|
|
|
|
def remove_special_chars(text): |
|
|
"""Remove or replace special characters that can't be spoken""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
text = text.replace('&', ' và ') |
|
|
text = text.replace('@', ' a còng ') |
|
|
text = text.replace('#', ' thăng ') |
|
|
text = text.replace('*', '') |
|
|
text = text.replace('_', ' ') |
|
|
text = text.replace('~', '') |
|
|
text = text.replace('`', '') |
|
|
text = text.replace('^', '') |
|
|
|
|
|
|
|
|
text = re.sub(r'https?://\S+', '', text) |
|
|
text = re.sub(r'www\.\S+', '', text) |
|
|
|
|
|
|
|
|
text = re.sub(r'\S+@\S+\.\S+', '', text) |
|
|
|
|
|
return text |
|
|
|
|
|
|
|
|
def normalize_punctuation(text): |
|
|
"""Normalize punctuation marks""" |
|
|
|
|
|
text = re.sub(r'[""„‟]', '"', text) |
|
|
text = re.sub(r"[''‚‛]", "'", text) |
|
|
|
|
|
|
|
|
text = re.sub(r'[–—−]', '-', text) |
|
|
|
|
|
|
|
|
text = re.sub(r'\.{3,}', '...', text) |
|
|
text = text.replace('…', '...') |
|
|
|
|
|
|
|
|
text = re.sub(r'([!?.]){2,}', r'\1', text) |
|
|
|
|
|
return text |
|
|
|
|
|
|
|
|
def process_vietnamese_text(text): |
|
|
""" |
|
|
Main function to process Vietnamese text for TTS. |
|
|
Applies all normalization steps in the correct order. |
|
|
|
|
|
Args: |
|
|
text: Raw Vietnamese text |
|
|
|
|
|
Returns: |
|
|
Normalized text suitable for TTS |
|
|
""" |
|
|
|
|
|
text = normalize_unicode(text) |
|
|
|
|
|
|
|
|
text = remove_special_chars(text) |
|
|
|
|
|
|
|
|
text = normalize_punctuation(text) |
|
|
|
|
|
|
|
|
text = convert_year_range(text) |
|
|
|
|
|
|
|
|
text = convert_date(text) |
|
|
|
|
|
|
|
|
text = convert_time(text) |
|
|
|
|
|
|
|
|
text = convert_ordinal(text) |
|
|
|
|
|
|
|
|
text = convert_currency(text) |
|
|
|
|
|
|
|
|
text = convert_percentage(text) |
|
|
|
|
|
|
|
|
text = convert_phone_number(text) |
|
|
|
|
|
|
|
|
text = convert_decimal(text) |
|
|
|
|
|
|
|
|
text = convert_standalone_numbers(text) |
|
|
|
|
|
|
|
|
text = clean_whitespace(text) |
|
|
|
|
|
return text |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
|
|
|
test_cases = [ |
|
|
"Lúc khoảng 2 giờ 20 phút sáng ngày thứ Bảy hay 8 tháng 11", |
|
|
"Alfred Jarry 1873-1907 hợp những nhà văn", |
|
|
"ông Derringer 44 ly, dí sát đầu tổng thống", |
|
|
"Giá sản phẩm là 100.000đ", |
|
|
"Tỷ lệ thành công đạt 85%", |
|
|
"Họp lúc 14:30", |
|
|
"Sinh ngày 15/08/1990", |
|
|
"Chương 3: Hành trình mới", |
|
|
"Số điện thoại: 0912345678", |
|
|
"Nhiệt độ 25.5 độ C", |
|
|
"Công ty XYZ có 1500 nhân viên", |
|
|
] |
|
|
|
|
|
print("=" * 60) |
|
|
print("Vietnamese Text Processor Test") |
|
|
print("=" * 60) |
|
|
|
|
|
for text in test_cases: |
|
|
processed = process_vietnamese_text(text) |
|
|
print(f"\nOriginal: {text}") |
|
|
print(f"Processed: {processed}") |
|
|
|