lct-hack/backend/app/voice/models.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

121 lines
5.6 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.

"""Модели речи процесса: грузятся один раз, общие для всех сессий.
Инференс занимает процессор на сотни миллисекунд и идёт в отдельном пуле потоков:
в событийном цикле он остановил бы все сокеты всех экранов занятия.
onnxruntime и torch отпускают GIL на время вычислений, так что потоки работают параллельно.
"""
import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor
from functools import lru_cache
from pathlib import Path
import numpy as np
from app.config import get_settings
from app.voice.text import normalize
log = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parents[2]
TTS_RATE = 24_000
#: Потоков на распознавание и на синтез. Вместе — меньше числа ядер, иначе
#: модели, идущие друг за другом, вытесняют друг друга.
INFERENCE_THREADS = 4
_pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="voice")
class Recognizer:
"""GigaAM v3 RNNT int8: ~285 мс на фразу в 2 с, WER 0% на чистой речи (docs/LATENCY.md)."""
def __init__(self, model_dir: Path) -> None:
import onnx_asr
import onnxruntime as ort
options = ort.SessionOptions()
# По умолчанию onnxruntime берёт все ядра и вытесняет синтез, который
# идёт следом: в контуре распознавание выходило вдвое медленнее замера.
options.intra_op_num_threads = INFERENCE_THREADS
self._model = onnx_asr.load_model(
"gigaam-v3-rnnt", model_dir, quantization="int8", sess_options=options
)
def transcribe(self, audio: np.ndarray) -> str:
return self._model.recognize(audio, sample_rate=16_000).strip()
def warmup(self) -> None:
"""Первый вызов холодный: выделение памяти, подготовка графа."""
rng = np.random.default_rng(0)
self.transcribe((rng.normal(0, 0.05, 16_000)).astype(np.float32))
class Synthesizer:
"""Silero v5: около десятой доли длительности фразы на синтез (docs/LATENCY.md)."""
def __init__(self, model_path: Path, speaker: str = "xenia") -> None:
import torch
torch.set_num_threads(INFERENCE_THREADS)
# Профилирующий компилятор на новых длинах входа не выигрывает: без него −13%.
torch._C._jit_set_profiling_executor(False)
importer = torch.package.PackageImporter(str(model_path))
self._model = importer.load_pickle("tts_models", "model")
self._model.to(torch.device("cpu"))
self.speaker = speaker
def synthesize(self, text: str) -> bytes:
"""PCM16 24 кГц. Пустая реплика — пустые байты: Silero на пустой строке падает."""
spoken = normalize(text)
if not spoken:
return b""
audio = self._model.apply_tts(text=spoken, speaker=self.speaker, sample_rate=TTS_RATE)
pcm = (audio.clamp(-1, 1).numpy() * 32767).astype(np.int16)
return pcm.tobytes()
def warmup(self) -> None:
for text in ("Алло!", "Улица Ленина, дом четырнадцать, квартира сорок семь."):
self.synthesize(text)
class VoiceModels:
def __init__(self, recognizer: Recognizer, synthesizer: Synthesizer, vad_path: Path) -> None:
self.recognizer = recognizer
self.synthesizer = synthesizer
self.vad_path = vad_path
async def transcribe(self, audio: np.ndarray) -> str:
return await asyncio.get_running_loop().run_in_executor(_pool, self.recognizer.transcribe, audio)
async def synthesize(self, text: str) -> bytes:
return await asyncio.get_running_loop().run_in_executor(_pool, self.synthesizer.synthesize, text)
@lru_cache(maxsize=1)
def get_voice_models() -> VoiceModels | None:
"""None, если голос выключен или моделей нет: занятие идёт без голоса,
и это видно в /api/health, а не падением на первом звонке."""
settings = get_settings()
if not settings.voice_enabled:
return None
models = ROOT / settings.models_dir
required = [
models / "gigaam-v3-onnx" / "v3_rnnt_encoder.int8.onnx",
models / "silero-vad" / "silero_vad.onnx",
models / "silero-tts" / "v5_ru.pt",
]
missing = [str(path.relative_to(ROOT)) for path in required if not path.exists()]
if missing:
log.warning("голосовой контур выключен, нет моделей: %s — make models", ", ".join(missing))
return None
try:
recognizer = Recognizer(models / "gigaam-v3-onnx")
synthesizer = Synthesizer(models / "silero-tts" / "v5_ru.pt")
# Прогрев здесь, на старте стенда, а не на первой реплике курсанта.
recognizer.warmup()
synthesizer.warmup()
return VoiceModels(recognizer, synthesizer, models / "silero-vad" / "silero_vad.onnx")
except ImportError as exc:
log.warning("голосовой контур выключен: не установлены зависимости (%s) — uv sync --extra voice", exc)
return None