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
121
backend/app/voice/models.py
Normal file
121
backend/app/voice/models.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Модели речи процесса: грузятся один раз, общие для всех сессий.
|
||||
|
||||
Инференс занимает процессор на сотни миллисекунд и идёт в отдельном пуле потоков:
|
||||
в событийном цикле он остановил бы все сокеты всех экранов занятия.
|
||||
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
|
||||
Loading…
Reference in a new issue