lct-hack/backend/app/voice/text.py
Ivan Gerasimov 456f3cd34d lct-06: голосовой контур VAD → STT → звонящий → TTS, с перебиванием
Контур замкнут без LLM: звонящий отвечает заготовками через слот-автомат.
Живой диалог через WebSocket прошёл 5 раз подряд без сбоев: первая
реплика после снятия трубки — 87–154 мс, ход — 1.2–1.4 с, перебивание —
88–90 мс. На LLM до цели 1.5 с остаётся 100–300 мс.

Задача ответа живёт, пока у курсанта доигрывает звук: перебивание —
это отмена одной задачи, и tts.end приходит, когда звонящий
действительно замолчал.

Что нашлось при сборке:
- Silero молча выбрасывает цифры: «улица Ленина, дом… квартира…» без
  номеров. Числа и сокращения разворачиваются в слова до синтеза.
- onnxruntime брал все 16 ядер и вытеснял синтез, первый вызов был
  холодным: распознавание 576 мс вместо ~210. Прогрев на старте и по
  4 потока на модель.
- Весь адрес одним предложением — ~455 мс синтеза до первого звука.
  Длинное предложение режется по запятым, номер от улицы не отрывается:
  ~250 мс.
- Первая реплика «Алло! Помогите!» запускалась мимо задачи ответа и не
  перебивалась вовсе.

Отдельно: сломал make dev — num2words попал в pyproject, а Dockerfile
ставил зависимости ручным списком, и бэкенд в контейнере упал на
импорте. Образ теперь ставит зависимости из pyproject.toml.

Голоса в контейнере нет: под WSL модели работают нативно, make back.
2026-09-17 15:04:17 +03:00

100 lines
5.3 KiB
Python
Raw 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.

"""Текст реплики → текст, который Silero произнесёт целиком.
Silero v5 **молча выбрасывает цифры и латиницу**: «улица Ленина, 14, квартира 47,
5-й этаж» звучит на 1.6 с короче того же адреса словами — номера дома, квартиры
и этажа не произносятся вовсе. Оператор при любом опросе не услышал бы адрес,
а это главный факт упражнения. Поэтому всё, что в фактах записано цифрами
и сокращениями, разворачивается в слова до синтеза.
"""
import re
from num2words import num2words
ABBREVIATIONS = {
r"\bул\.": "улица",
r"\bд\.": "дом",
r"\bкв\.": "квартира",
r"\bпр-т\b": "проспект",
r"\bпер\.": "переулок",
r"\bкорп\.": "корпус",
r"\bстр\.": "строение",
r"\bпод\.": "подъезд",
r"\bэт\.": "этаж",
}
LATIN = {
"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": "зэт",
}
# Окончание порядкового числительного в записи «5-й», «5-я», «5-е» задаёт род.
_ORDINAL = re.compile(r"\b(\d+)-(й|я|е|го|му|м|х)\b")
_NUMBER = re.compile(r"\d+")
def _ordinal(number: int, suffix: str) -> str:
word = num2words(number, lang="ru", to="ordinal") # мужской род: «пятый»
if suffix == "я":
return re.sub(r"(ый|ой)$", "ая", re.sub(r"ий$", "ья" if word.endswith("тий") else "яя", word))
if suffix == "е":
return re.sub(r"(ый|ой)$", "ое", re.sub(r"ий$", "ье" if word.endswith("тий") else "ее", word))
return word
def normalize(text: str) -> str:
"""Вернуть текст, который синтез произнесёт без пропусков. Пустой — если говорить нечего."""
for pattern, full in ABBREVIATIONS.items():
text = re.sub(pattern, full, text, flags=re.IGNORECASE)
text = _ORDINAL.sub(lambda m: _ordinal(int(m.group(1)), m.group(2)), text)
# «47Б», «14B» — буква корпуса или квартиры прилипает к числу.
text = re.sub(r"(\d+)([A-Za-zА-Яа-я])\b", r"\1 \2", text)
text = _NUMBER.sub(lambda m: num2words(int(m.group()), lang="ru"), text)
text = re.sub(r"[A-Za-z]", lambda m: f" {LATIN[m.group().lower()]} ", text)
return re.sub(r"\s+", " ", text).strip()
_SENTENCE_END = re.compile(r"(?<=[.!?…])\s+")
def sentences(text: str) -> list[str]:
"""Реплика по предложениям: синтез стартует с первого, не дожидаясь остальных."""
return [part for part in (piece.strip() for piece in _SENTENCE_END.split(text)) if part]
#: Предложение, которое **звучит** длиннее этого, режется по запятым. Первый звук
#: ждёт синтеза первого куска целиком: адрес одним предложением — 4 с звука и ~450 мс
#: синтеза, первая часть «Улица Ленина, дом четырнадцать» — вдвое быстрее.
#: Длина меряется по произносимому тексту: «14» в записи — два символа, в звуке —
#: «четырнадцать».
LONG_SENTENCE = 40
MIN_CHUNK = 25
def speech_chunks(text: str) -> list[str]:
"""Куски для синтеза: предложения, а длинные — ещё и по запятым.
Следующий кусок синтезируется, пока играет предыдущий: синтез в 10 раз быстрее
реального времени, и стыка не слышно. Совсем короткие куски приклеиваются
к соседним — отдельно синтезированное «дом» звучит обрывком.
"""
chunks: list[str] = []
for sentence in sentences(text):
if len(normalize(sentence)) <= LONG_SENTENCE:
chunks.append(sentence)
continue
parts = [part.strip() for part in re.split(r"(?<=,)\s+", sentence) if part.strip()]
current = ""
for index, part in enumerate(parts):
current = f"{current} {part}".strip()
last = index == len(parts) - 1
# Номер не отрывается от улицы: «Ленина» / «четырнадцать» звучит как
# два факта, и оператор расслышит улицу, но потеряет дом.
number_follows = not last and parts[index + 1][:1].isdigit()
if last or (len(normalize(current)) >= MIN_CHUNK and not number_follows):
chunks.append(current)
current = ""
return chunks