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.
This commit is contained in:
parent
dcdec56779
commit
456f3cd34d
18 changed files with 933 additions and 15 deletions
100
backend/app/voice/text.py
Normal file
100
backend/app/voice/text.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Текст реплики → текст, который 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
|
||||
Loading…
Reference in a new issue