Локальная озвучка через Silero: нормализатор русского текста под TTS, рендер по главам, замечание про AVX2

This commit is contained in:
Илья Поляков
2026-08-03 14:58:54 +03:00
parent 3bbd75b1c3
commit 6c81673b4f
3 changed files with 346 additions and 2 deletions
+162
View File
@@ -0,0 +1,162 @@
#!/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')