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
|
|
|
|
"""Модели речи процесса: грузятся один раз, общие для всех сессий.
|
|
|
|
|
|
|
|
|
|
|
|
Инференс занимает процессор на сотни миллисекунд и идёт в отдельном пуле потоков:
|
|
|
|
|
|
в событийном цикле он остановил бы все сокеты всех экранов занятия.
|
|
|
|
|
|
onnxruntime и torch отпускают GIL на время вычислений, так что потоки работают параллельно.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
2026-09-24 01:10:49 +03:00
|
|
|
|
import io
|
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
|
|
|
|
import logging
|
2026-09-24 01:10:49 +03:00
|
|
|
|
import wave
|
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
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
|
|
from functools import lru_cache
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
2026-09-24 01:10:49 +03:00
|
|
|
|
import httpx
|
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
|
|
|
|
|
|
|
|
|
|
from app.config import get_settings
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.dialog.llm import is_loopback_url
|
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
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
class WhisperRecognizer:
|
|
|
|
|
|
"""Локальный whisper.cpp: GGML Whisper small, без облака и Python-зависимости STT.
|
|
|
|
|
|
|
|
|
|
|
|
VAD уже отделил реплику. Сервер держит модель в памяти, а Python отправляет
|
|
|
|
|
|
ему только 16-кГц WAV через loopback; адрес из конфигурации проверяется.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, server_url: str, client: httpx.Client | None = None) -> None:
|
|
|
|
|
|
if not is_loopback_url(server_url):
|
|
|
|
|
|
raise ValueError("Whisper server должен слушать только loopback")
|
|
|
|
|
|
self._url = server_url.rstrip("/")
|
|
|
|
|
|
self._client = client or httpx.Client(trust_env=False, timeout=30.0)
|
|
|
|
|
|
|
|
|
|
|
|
def transcribe(self, audio: np.ndarray) -> str:
|
|
|
|
|
|
pcm = (np.clip(audio, -1, 1) * 32767).astype("<i2")
|
|
|
|
|
|
with io.BytesIO() as buffer:
|
|
|
|
|
|
with wave.open(buffer, "wb") as wav:
|
|
|
|
|
|
wav.setnchannels(1)
|
|
|
|
|
|
wav.setsampwidth(2)
|
|
|
|
|
|
wav.setframerate(16_000)
|
|
|
|
|
|
wav.writeframes(pcm.tobytes())
|
|
|
|
|
|
payload = buffer.getvalue()
|
|
|
|
|
|
response = self._client.post(
|
|
|
|
|
|
self._url + "/inference",
|
|
|
|
|
|
files={"file": ("utterance.wav", payload, "audio/wav")},
|
|
|
|
|
|
data={"language": "ru", "response_format": "json", "temperature": "0",
|
|
|
|
|
|
"temperature_inc": "0", "no_context": "true"},
|
|
|
|
|
|
)
|
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
result = response.json()
|
|
|
|
|
|
if not isinstance(result, dict) or not isinstance(result.get("text"), str):
|
|
|
|
|
|
raise ValueError("некорректный ответ локального Whisper server")
|
|
|
|
|
|
return result["text"].strip()
|
|
|
|
|
|
|
|
|
|
|
|
def warmup(self) -> None:
|
|
|
|
|
|
# GET / отвечает локальная страница server.cpp. Не запускаем тяжёлый
|
|
|
|
|
|
# инференс на тишине: сервер загрузил модель до готовности порта.
|
|
|
|
|
|
response = self._client.get(self._url + "/", timeout=2.0)
|
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
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:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
def __init__(self, recognizer: Recognizer | WhisperRecognizer, synthesizer: Synthesizer,
|
|
|
|
|
|
vad_path: Path) -> None:
|
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
|
|
|
|
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
|
2026-09-24 01:10:49 +03:00
|
|
|
|
stt_path = (ROOT / settings.whisper_model_path / "ggml-small-q5_1.bin"
|
|
|
|
|
|
if settings.stt_model == "whisper-small"
|
|
|
|
|
|
else models / "gigaam-v3-onnx" / "v3_rnnt_encoder.int8.onnx")
|
|
|
|
|
|
if settings.stt_model not in {"whisper-small", "gigaam-v3-rnnt"}:
|
|
|
|
|
|
log.error("неизвестный STT_MODEL: %s", settings.stt_model)
|
|
|
|
|
|
return None
|
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
|
|
|
|
required = [
|
2026-09-24 01:10:49 +03:00
|
|
|
|
stt_path,
|
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
|
|
|
|
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:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
recognizer = (WhisperRecognizer(settings.whisper_server_url) if settings.stt_model == "whisper-small"
|
|
|
|
|
|
else Recognizer(models / "gigaam-v3-onnx"))
|
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
|
|
|
|
synthesizer = Synthesizer(models / "silero-tts" / "v5_ru.pt")
|
|
|
|
|
|
# Прогрев здесь, на старте стенда, а не на первой реплике курсанта.
|
|
|
|
|
|
recognizer.warmup()
|
|
|
|
|
|
synthesizer.warmup()
|
|
|
|
|
|
return VoiceModels(recognizer, synthesizer, models / "silero-vad" / "silero_vad.onnx")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
except (ImportError, httpx.HTTPError, ValueError) as exc:
|
|
|
|
|
|
log.warning("голосовой контур выключен: модели/сервер не готовы (%s)", exc)
|
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
|
|
|
|
return None
|