Files

163 lines
9.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Нормализация русского текста под русский TTS.
Латиницу русский голос читает плохо, поэтому она вся уходит в кириллицу:
именованные сущности по ручному словарю, остальное — общими правилами.
"""
import re
# Имена собственные и термины, где побуквенная транслитерация даёт мимо.
# Ударение помечается «+» перед гласной.
NAMES = {
'Parts Unlimited': 'П+артс Анлим+итед', 'Wayne-Yokohama': 'У+эйн-Йокох+ама',
'Equity Partners': '+Эквити П+артнерс', 'Data Hub': 'Д+ата Хаб',
'Phoenix': 'Ф+еникс', 'Unicorn': 'Ю+никорн', 'Narwhal': 'Н+арвал',
'Panther': 'П+антер', 'Horizon': 'Хор+айзон', 'Summit': 'С+аммит',
'Kumquat': 'К+амкват', 'Inversion': 'Инв+ерсия', 'Rebellion': 'Ребелл+ион',
'Orca': '+Орка', 'Shamu': 'Шам+у', 'Unikitty': 'Юник+итти',
'Dockside': 'Д+оксайд', 'Tomcat': 'Т+омкэт', 'Makefile': 'М+ейкфайл',
'Microsoft': 'Майкрос+офт', 'Google': 'Г+угл', 'Amazon': 'Ам+азон',
'Apple': '+Эппл', 'Netflix': 'Н+етфликс', 'Facebook': 'Ф+ейсбук',
'Twitter': 'Тв+иттер', 'YouTube': 'Ють+юб', 'Toyota': 'Той+ота',
'Nokia': 'Н+окиа', 'Alcoa': 'Алк+оа', 'Walmart': 'Уолм+арт',
'Tesla': 'Т+есла', 'Uber': '+Убер', 'Lyft': 'Лифт', 'Blockbuster': 'Блокб+астер',
'Compuware': 'Компь+юуэр', 'Symbian': 'С+имбиан', 'Windows': 'В+индоус',
'Linux': 'Л+инукс', 'Android': 'Андр+оид', 'iPhone': 'Айф+он',
'Python': 'П+айтон', 'Java': 'Дж+ава', 'Clojure': 'Кл+ожур',
'Docker': 'Д+окер', 'Git': 'Гит', 'Excel': '+Эксель', 'Word': 'Ворд',
'PowerPoint': 'П+ауэр-П+ойнт', 'SharePoint': 'Шер-П+ойнт',
'Visio': 'В+изио', 'OmniGraffle': 'Омни-Гр+аффл', 'Office': '+Офис',
'DevOps': 'Дев-+Опс', 'NoSQL': 'Ноу-эс-кью-+эль', 'Agile': '+Эджайл',
'San Francisco': 'Сан-Франц+иско', 'Las Vegas': 'Лас-В+егас',
'London': 'Л+ондон', 'Elkhart Grove': '+Элкхарт-Гр+оув',
'Gene Kim': 'Джин Ким', 'Maxine': 'Макс+ин',
}
# Аббревиатуры: как их произносят по-русски
ACRONYMS = {
'IT': 'айт+и', 'API': 'эй-пи-+ай', 'QA': 'кь+ю-эй', 'HR': 'эйч+ар',
'VP': 'вип+и', 'CIO': 'си-ай-+о', 'CEO': 'си-и-+о', 'CTO': 'си-ти-+о',
'MRP': 'эм-эр-п+и', 'ERP': 'и-ар-п+и', 'CRM': 'си-эр-+эм',
'USB': 'ю-эс-б+и', 'SKU': 'эс-кей-+ю', 'BOM': 'би-о-+эм',
'VIN': 'вин', 'ETL': 'и-ти-+эль', 'PII': 'пи-ай-+ай',
'ITIL': '+айтил', 'CI': 'си-+ай', 'CD': 'си-д+и', 'DSL': 'ди-эс-+эль',
'HTML': 'эйч-ти-эм-+эль', 'IP': 'ай-п+и', 'SQL': 'эс-кью-+эль',
'PhD': 'пи-эйч-д+и', 'ISBN': 'и-эс-би-+эн', 'OEM': 'о-и-+эм',
'TEP': 'тэп', 'LARB': 'ларб', 'CSG': 'си-эс-дж+и', 'SPSS': 'эс-пи-эс-+эс',
'PUL': 'пи-ю-+эль', 'DEVP': 'дев-п+и', 'R': 'эр', 'v': 'в+ерсия',
}
DIGITS = ['ноль', 'один', 'два', 'три', 'четыре', 'пять',
'шесть', 'семь', 'восемь', 'девять']
TENS = {10: 'десять', 11: 'одиннадцать', 12: 'двенадцать', 13: 'тринадцать',
14: 'четырнадцать', 15: 'пятнадцать', 16: 'шестнадцать',
17: 'семнадцать', 18: 'восемнадцать', 19: 'девятнадцать',
20: 'двадцать', 30: 'тридцать', 40: 'сорок', 50: 'пятьдесят',
60: 'шестьдесят', 70: 'семьдесят', 80: 'восемьдесят', 90: 'девяносто'}
HUNDREDS = {100: 'сто', 200: 'двести', 300: 'триста', 400: 'четыреста',
500: 'пятьсот', 600: 'шестьсот', 700: 'семьсот',
800: 'восемьсот', 900: 'девятьсот'}
# английская орфография -> русское звучание, длинные сочетания раньше коротких
TRANSLIT = [
('tch', 'ч'), ('sch', 'ш'), ('sh', 'ш'), ('ch', 'ч'), ('th', 'т'),
('ph', 'ф'), ('ck', 'к'), ('qu', 'кв'), ('oo', 'у'), ('ee', 'и'),
('ea', 'и'), ('ou', 'ау'), ('ow', 'оу'), ('ay', 'ей'), ('ai', 'ей'),
('ey', 'и'), ('oy', 'ой'), ('au', 'о'), ('aw', 'о'), ('igh', 'ай'),
('a', 'а'), ('b', 'б'), ('c', 'к'), ('d', 'д'), ('e', 'е'), ('f', 'ф'),
('g', 'г'), ('h', 'х'), ('i', 'и'), ('j', 'дж'), ('k', 'к'), ('l', 'л'),
('m', 'м'), ('n', 'н'), ('o', 'о'), ('p', 'п'), ('q', 'к'), ('r', 'р'),
('s', 'с'), ('t', 'т'), ('u', 'у'), ('v', 'в'), ('w', 'у'), ('x', 'кс'),
('y', 'й'), ('z', 'з'),
]
def number_to_words(n):
n = int(n)
if n < 10:
return DIGITS[n]
if n in TENS:
return TENS[n]
if n < 100:
return '%s %s' % (TENS[n // 10 * 10], DIGITS[n % 10])
if n in HUNDREDS:
return HUNDREDS[n]
if n < 1000:
return '%s %s' % (HUNDREDS[n // 100 * 100], number_to_words(n % 100))
if n < 10000 and n % 1000 == 0:
return '%s тысяч' % DIGITS[n // 1000]
return ' '.join(DIGITS[int(c)] for c in str(n))
def translit_word(w):
"""Незнакомое латинское слово — общими правилами чтения."""
low = w.lower()
out = low
for src, dst in TRANSLIT:
out = out.replace(src, dst)
out = re.sub(r'[^а-яё]', '', out)
return out.capitalize() if w[:1].isupper() else out
def normalize(text):
# ссылки читать бессмысленно
text = re.sub(r'https?://\S+', ' ссылка ', text)
text = re.sub(r'\bwww\.\S+', ' ссылка ', text)
# именованные сущности (сначала многословные)
for name in sorted(NAMES, key=len, reverse=True):
text = re.sub(re.escape(name), NAMES[name], text)
# время 6:07
text = re.sub(r'\b(\d{1,2}):(\d{2})\b',
lambda m: '%s %s' % (number_to_words(m.group(1)),
' '.join(DIGITS[int(c)] for c in m.group(2))),
text)
# идентификаторы: MRP-8, DEVP-101
text = re.sub(r'\b([A-Za-z]{2,6})-(\d+)\b',
lambda m: '%s %s' % (ACRONYMS.get(m.group(1).upper(),
translit_word(m.group(1))),
number_to_words(m.group(2))), text)
# аббревиатуры
for a in sorted(ACRONYMS, key=len, reverse=True):
text = re.sub(r'\b%s\b' % re.escape(a), ACRONYMS[a], text)
# оставшаяся латиница — общими правилами
text = re.sub(r'\b[A-Za-z][A-Za-z\'-]*\b', lambda m: translit_word(m.group(0)), text)
# числа
text = re.sub(r'\b\d+\b', lambda m: number_to_words(m.group(0)), text)
# мусор после замен
text = re.sub(r'[ \t]{2,}', ' ', text)
return text.strip()
TITLES = {
7: 'Пролог', 34: 'Эпилог',
4: 'Посвящение', 5: 'Обращение к читателю',
6: 'Действующие лица', 8: 'Газетная заметка',
9: 'Часть первая', 18: 'Часть вторая', 26: 'Часть третья',
35: 'Должностная инструкция', 36: 'Пять идеалов',
38: 'Благодарности', 39: 'Об авторе',
}
ORDINAL = ['первая', 'вторая', 'третья', 'четвёртая', 'пятая', 'шестая',
'седьмая', 'восьмая', 'девятая', 'десятая', 'одиннадцатая',
'двенадцатая', 'тринадцатая', 'четырнадцатая', 'пятнадцатая',
'шестнадцатая', 'семнадцатая', 'восемнадцатая', 'девятнадцатая']
def chapter_title(num, raw):
if num in TITLES:
return TITLES[num]
m = re.search(r'CHAPTER\s+(\d+)', raw, re.I)
if m:
i = int(m.group(1))
return 'Глава %s' % (ORDINAL[i - 1] if i <= len(ORDINAL) else number_to_words(i))
return None # титульные страницы и прочее без озвучиваемого заголовка
if __name__ == '__main__':
for s in ['Максин открыла Phoenix в SharePoint через API за 20 минут.',
'Сервер DEVP-101 и MRP-8 в Parts Unlimited, встреча в 6:07.',
'Смотри https://example.com/page и репозиторий Git.',
'Кофе с CIO и VP по вопросам IT и QA.']:
print(' ', s, '\n ->', normalize(s), '\n')