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:
Ivan Gerasimov 2026-09-17 15:04:17 +03:00
commit 456f3cd34d
18 changed files with 933 additions and 15 deletions

View file

@ -15,8 +15,8 @@ dev: ## Поднять стенд: postgres + backend --reload + frontend
down: ## Погасить стенд
$(COMPOSE) down
back: ## Только бэкенд, локально, без докера
cd backend && $(UV) run uvicorn app.main:app --reload --port 8000
back: ## Бэкенд нативно, с голосовым контуром (порт 8000: сначала docker compose stop backend)
cd backend && $(UV) run --extra voice uvicorn app.main:app --reload --port 8000 --workers 1
front: ## Только фронтенд, локально
npm --prefix frontend install && npm --prefix frontend run dev
@ -24,9 +24,12 @@ front: ## Только фронтенд, локально
types: ## domain/events.py → frontend/src/shared/types/generated.ts
cd backend && $(UV) run --no-project --with 'pydantic>=2.7' python scripts/export_types.py
test: ## Тесты бэкенда
test: ## Тесты бэкенда (голосовой контур пропускается)
cd backend && $(UV) run --extra dev pytest -q
test-voice: ## Тест голосового контура на настоящих моделях: задержка и перебивание
cd backend && $(UV) run --extra dev --extra voice pytest tests/test_voice_pipeline.py -q -s
typecheck: ## Проверить фронтенд компилятором
npm --prefix frontend run typecheck
@ -57,4 +60,4 @@ pregen: ## Дерево диалога и WAV первых реплик для
demo: ## Поднять всё в демо-режиме: офлайн, прогретые модели
@echo "не реализовано — карточка tasks/lct-21-demo-readiness.md"; exit 1
.PHONY: help dev down back front types test typecheck lesson latency migrate revision models seed repl pregen demo
.PHONY: help dev down back front types test test-voice typecheck lesson latency migrate revision models seed repl pregen demo

View file

@ -3,12 +3,14 @@ FROM python:3.11-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
ENV UV_PROJECT_ENVIRONMENT=/usr/local PYTHONUNBUFFERED=1
ENV PYTHONUNBUFFERED=1
# Зависимости — из pyproject.toml, а не отдельным списком: ручной список разошёлся
# с проектом, и бэкенд в контейнере упал на первом же новом пакете (num2words).
# Голосовой контур (группа voice: torch, onnx-asr) в образ не входит: под WSL
# модели запускаются нативно — так они и мерялись (docs/LATENCY.md).
COPY pyproject.toml ./
RUN uv pip install --system fastapi "uvicorn[standard]" "pydantic>=2.7" pydantic-settings \
"sqlalchemy[asyncio]" alembic asyncpg httpx pyyaml structlog \
onnxruntime tokenizers numpy
RUN uv pip install --system -r pyproject.toml
COPY . .
EXPOSE 8000

View file

@ -25,9 +25,12 @@ from app.domain.events import (
TimerTick,
TraineeToServer,
)
from app.domain.events import BgStart
from app.scenarios import store
from app.session.hub import hub
from app.session.state import now_utc
from app.voice.models import get_voice_models
from app.voice.pipeline import VoiceSession
log = logging.getLogger(__name__)
router = APIRouter()
@ -40,7 +43,7 @@ _adapter = TypeAdapter(TraineeToServer)
def _on_audio(session_id: UUID, state, frame: bytes) -> None:
"""Приём аудиокадра. Голосовой контур (lct-06) заменит счёт на VAD → STT."""
"""Приём аудиокадра: в голосовой контур, а без него — только счёт."""
if len(frame) != FRAME_BYTES:
state.bad_frames += 1
if state.bad_frames == 1:
@ -49,6 +52,8 @@ def _on_audio(session_id: UUID, state, frame: bytes) -> None:
session_id, len(frame), FRAME_BYTES)
return
state.audio_frames += 1
if state.voice is not None:
state.voice.feed(frame)
if state.audio_frames % FRAMES_PER_LOG == 0:
log.info("сессия %s: получено %d кадров (%.0f с звука)",
session_id, state.audio_frames, state.audio_frames * 0.02)
@ -81,6 +86,7 @@ async def _handle(session_id: UUID, state, event) -> None:
hub.to_observers(session_id, state.snapshot())
if hub.journal:
await hub.journal.session_started(session_id, state.started_at)
_start_voice(session_id, state)
case "kio.patch":
state.patch_kio(event.fields)
@ -125,6 +131,8 @@ async def _handle(session_id: UUID, state, event) -> None:
)
case "call.hangup":
if state.voice is not None:
await state.voice.close()
state.ended_at = now_utc()
state.end_reason = CallEndReason.HANGUP
hub.stop_ticker(session_id)
@ -136,10 +144,35 @@ async def _handle(session_id: UUID, state, event) -> None:
)
def _start_voice(session_id: UUID, state) -> None:
"""Голос включается, когда курсант снял трубку: звонящий сразу кричит первую реплику."""
models = get_voice_models()
scenario = store.get(state.scenario_id)
if models is None or scenario is None or state.voice is not None:
return
state.voice = VoiceSession(
session_id=session_id,
state=state,
models=models,
send_event=lambda event: hub.to_trainee(session_id, event),
send_observer=lambda event: hub.to_observers(session_id, event),
send_audio=lambda pcm: hub.to_trainee(session_id, pcm),
journal=hub.journal,
)
if scenario.background:
event = BgStart(loop=scenario.background.loop, gain_db=scenario.background.gain_db)
hub.broadcast(session_id, event)
state.voice.speak(scenario.first_line, state.persona.mood)
async def _pump(ws: WebSocket, queue: asyncio.Queue) -> None:
while True:
event = await queue.get()
await ws.send_text(event.model_dump_json())
item = await queue.get()
# Бинарь — звук звонящего, без обёртки JSON (docs/arch/CONTRACT.md).
if isinstance(item, bytes):
await ws.send_bytes(item)
else:
await ws.send_text(item.model_dump_json())
@router.websocket("/ws/call/{session_id}")

View file

@ -25,12 +25,17 @@ from app.domain.events import (
ReferenceStarted,
SessionEnded,
)
import asyncio
from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState
from app.dialog.runtime import get_embedder
from app.dialog.slots import SlotMachine
from app.scenarios import store
from app.session.hub import hub
from app.session.state import SessionState, now_utc
from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch
log = logging.getLogger(__name__)
router = APIRouter()
@ -69,6 +74,13 @@ async def _start(session_id: UUID, event) -> None:
if embedder is not None:
state.slots = SlotMachine(scenario, embedder)
state.persona = PersonaState(scenario.persona)
state.caller = TemplateCaller()
# Первая реплика и филлеры синтезируются, пока курсант не снял трубку:
# «Алло! Помогите!» должно прозвучать мгновенно (docs/arch/BACKEND.md).
models = get_voice_models()
if models is not None:
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
state.on_event("call.incoming")
hub.start_ticker(session_id)

View file

@ -21,6 +21,8 @@ class Settings(BaseSettings):
mic_sample_rate: int = 16_000
tts_sample_rate: int = 24_000
offline: bool = False
# Голосовой контур: грузит ~5 с моделей при старте. В тестах выключен.
voice_enabled: bool = True
# LLM. Провайдер меняется значением, не кодом (docs/arch/STACK.md).
# Имена COMPAT_MODEL_* принимаются тоже — так их выставляет командный сниппет.

View file

@ -15,6 +15,7 @@ from app.api.ws import observe as observe_ws
from app.config import get_settings
from app.db.base import get_sessionmaker
from app.dialog.runtime import get_embedder
from app.voice.models import get_voice_models
from app.scenarios import store
from app.session.hub import hub
from app.session.journal import DbJournal
@ -46,11 +47,14 @@ async def lifespan(app: FastAPI):
# Эмбеддинги для слот-автомата — грузятся один раз, до первого занятия.
app.state.embeddings_ready = get_embedder() is not None
# Прогрев моделей речи — карточка lct-06.
app.state.models_ready = False
# Модели речи: ~5 секунд на старте стенда вместо паузы на первом звонке.
app.state.models_ready = get_voice_models() is not None
yield
await hub.shutdown()
for state in list(hub._sessions.values()):
if state.voice is not None:
await state.voice.close()
app = FastAPI(title="Учебный симулятор занятия для системы 112", lifespan=lifespan)

View file

@ -79,7 +79,7 @@ class SessionHub:
# ── вещание ──
@staticmethod
def _put(queues: set[asyncio.Queue], event: BaseModel) -> None:
def _put(queues: set[asyncio.Queue], event: BaseModel | bytes) -> None:
for queue in list(queues):
try:
queue.put_nowait(event)
@ -89,7 +89,7 @@ class SessionHub:
def to_observers(self, session_id: UUID, event: BaseModel) -> None:
self._put(self._observers.get(session_id, set()), event)
def to_trainee(self, session_id: UUID, event: BaseModel) -> None:
def to_trainee(self, session_id: UUID, event: BaseModel | bytes) -> None:
self._put(self._trainees.get(session_id, set()), event)
def broadcast(self, session_id: UUID, event: BaseModel) -> None:

View file

@ -18,6 +18,7 @@ from app.domain.events import (
Speaker,
TranscriptEntry,
)
from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
from app.domain.kio import KIO, apply_patch
@ -50,6 +51,10 @@ class SessionState:
# занятие идёт, подсказки откатываются на порядок чек-листа.
slots: SlotMachine | None = None
persona: PersonaState | None = None
caller: TemplateCaller | None = None
# Голосовой контур звонка. Нет — если голос выключен или моделей нет:
# тогда кадры микрофона только считаются.
voice: object | None = None
# Аудио курсанта. До голосового контура (lct-06) кадры только считаются —
# этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера.

View file

121
backend/app/voice/models.py Normal file
View 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

View file

@ -0,0 +1,248 @@
"""Голосовой контур одного звонка: VAD → STT → звонящий → TTS, с перебиванием.
Задача ответа живёт, пока у курсанта **доигрывает звук**, а не только пока идёт
синтез. Поэтому barge-in — это просто отмена этой задачи: одним движением гасятся
распознавание, реплика звонящего, синтез и ожидание конца воспроизведения
(docs/arch/BACKEND.md), а `tts.end` приходит тогда, когда звонящий действительно
замолчал.
"""
import asyncio
import hashlib
import logging
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from uuid import UUID, uuid4
import numpy as np
from app.config import get_settings
from app.domain.events import (
CallerUtterance,
Mood,
Speaker,
SttFinal,
TranscriptAppend,
TtsBegin,
TtsCancel,
TtsEnd,
)
from app.voice.models import TTS_RATE, VoiceModels
from app.voice.text import sentences, speech_chunks
from app.voice.vad import SpeechEnded, SpeechStarted, StreamingVad
log = logging.getLogger(__name__)
#: Если ответ не готов за секунду — звонящий «переспрашивает». Маскирует паузу
#: и сюжетно оправдано для паникующего: это поведение персонажа, а не костыль.
FILLER_AFTER_S = 1.0
FILLERS: dict[Mood, str] = {
Mood.PANIC: "Алло?! Вы тут?!",
Mood.AGGRESSIVE: "Алло! Вы там уснули?!",
Mood.WORRIED: "Алло?..",
Mood.CALM: "Алло?",
Mood.CONFUSED: "Алло... кто это?",
}
CACHE = Path(__file__).resolve().parents[2] / get_settings().models_dir / "cache" / "tts"
@dataclass
class TurnTiming:
"""Разбивка задержки одного хода — то, что меряет DoD «≤ 1.5 с»."""
stt_ms: float = 0.0
caller_ms: float = 0.0
tts_first_ms: float = 0.0
speech_end_to_audio_ms: float = 0.0
filler: bool = False
@dataclass
class VoiceSession:
session_id: UUID
state: object # SessionState; без импорта, чтобы не завязать сессию на голос
models: VoiceModels
send_event: Callable[[object], None]
send_observer: Callable[[object], None]
send_audio: Callable[[bytes], None]
journal: object | None = None
timings: list[TurnTiming] = field(default_factory=list)
_vad: StreamingVad = field(init=False)
_queue: asyncio.Queue = field(init=False)
_worker: asyncio.Task | None = field(init=False, default=None)
_reply: asyncio.Task | None = field(init=False, default=None)
_utterance_id: UUID | None = field(init=False, default=None)
def __post_init__(self) -> None:
self._vad = StreamingVad(self.models.vad_path, endpointing_ms=get_settings().endpointing_ms)
self._queue = asyncio.Queue()
self._worker = asyncio.create_task(self._work())
# ── вход: кадры микрофона ──
def feed(self, frame: bytes) -> None:
for event in self._vad.push(frame):
if isinstance(event, SpeechStarted) and self.speaking:
self.barge_in()
elif isinstance(event, SpeechEnded):
self._queue.put_nowait((event.audio, time.monotonic()))
@property
def speaking(self) -> bool:
return self._reply is not None and not self._reply.done()
def speak(self, text: str, mood: Mood) -> asyncio.Task:
"""Реплика по инициативе звонящего — первая фраза, директива.
Запускается только так, а не прямым `say()`: перебивание отменяет
`self._reply`, и реплика мимо него была бы неперебиваемой. Именно так
первая фраза «Алло! Помогите!» — ровно та, которую оператор перебивает
чаще всего, — не гасилась вовсе.
"""
if self.speaking:
self._reply.cancel()
self._reply = asyncio.create_task(self.say(text, mood))
return self._reply
def barge_in(self) -> None:
"""Оператор перебил. Сервер — авторитет: гасит всё и говорит фронту
выбросить недоигранный звук."""
if not self.speaking:
return
self._reply.cancel()
if self._utterance_id is not None:
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="barge_in"))
log.info("сессия %s: перебивание", self.session_id)
async def close(self) -> None:
for task in (self._reply, self._worker):
if task is not None:
task.cancel()
# ── ответ звонящего ──
async def _work(self) -> None:
while True:
audio, ended_at = await self._queue.get()
# Новая фраза оператора, пока звонящий ещё говорит, — тоже перебивание.
if self.speaking:
self.barge_in()
self._reply = asyncio.create_task(self._respond(audio, ended_at))
try:
await self._reply
except asyncio.CancelledError:
if asyncio.current_task().cancelling():
raise # отменили сам контур, а не ответ
async def _respond(self, audio: np.ndarray, ended_at: float) -> None:
timing = TurnTiming()
started = time.monotonic()
text = await self.models.transcribe(audio)
timing.stt_ms = (time.monotonic() - started) * 1000
if not text:
return
entry = self.state.append(Speaker.OPERATOR, text)
self.send_event(SttFinal(text=text, at=entry.at))
self.send_observer(TranscriptAppend(entry=entry))
if self.journal:
await self.journal.utterance(self.session_id, entry)
started = time.monotonic()
line = self._caller_line(text)
timing.caller_ms = (time.monotonic() - started) * 1000
await self.say(line.text, line.mood, ended_at=ended_at, timing=timing)
def _caller_line(self, text: str):
state = self.state
if state.slots is None or state.caller is None or state.persona is None:
from app.dialog.caller import CallerLine
return CallerLine(text=FILLERS[Mood.PANIC], mood=Mood.PANIC)
turn = state.slots.hear(text)
return state.caller.reply(turn, state.persona, state.slots)
async def say(
self, text: str, mood: Mood, *, ended_at: float | None = None, timing: TurnTiming | None = None
) -> None:
"""Произнести реплику: событие с текстом, звук по предложениям, ожидание конца."""
self._utterance_id = utterance_id = uuid4()
entry = self.state.append(Speaker.CALLER, text, mood)
self.send_event(CallerUtterance(utterance_id=utterance_id, text=text, at=entry.at, mood=mood))
self.send_observer(TranscriptAppend(entry=entry))
if self.journal:
await self.journal.utterance(self.session_id, entry)
self.send_event(TtsBegin(utterance_id=utterance_id))
playback_ends = time.monotonic()
for index, sentence in enumerate(speech_chunks(text)):
synth_started = time.monotonic()
if index == 0 and ended_at is not None:
pcm = await self._first_sentence(sentence, mood, ended_at, timing)
else:
pcm = await self.synthesize(sentence)
if index == 0 and timing is not None:
timing.tts_first_ms = (time.monotonic() - synth_started) * 1000
if not pcm:
continue
if index == 0 and ended_at is not None and timing is not None:
timing.speech_end_to_audio_ms = (time.monotonic() - ended_at) * 1000
self.send_audio(pcm)
playback_ends = max(playback_ends, time.monotonic()) + len(pcm) / 2 / TTS_RATE
if timing is not None:
self.timings.append(timing)
log.info(
"сессия %s: ответ через %.0f мс после конца фразы (STT %.0f, звонящий %.0f, TTS %.0f%s)",
self.session_id, timing.speech_end_to_audio_ms, timing.stt_ms,
timing.caller_ms, timing.tts_first_ms, ", с филлером" if timing.filler else "",
)
# Ждём, пока курсант дослушает: перебивание в это время отменит задачу.
await asyncio.sleep(max(0.0, playback_ends - time.monotonic()))
self.send_event(TtsEnd(utterance_id=utterance_id))
async def _first_sentence(
self, sentence: str, mood: Mood, ended_at: float, timing: TurnTiming | None
) -> bytes:
"""Первое предложение с филлером: если к секунде после конца фразы звука
ещё нет, звонящий «переспрашивает», а ответ встанет в очередь за ним."""
synthesis = asyncio.ensure_future(self.synthesize(sentence))
remaining = FILLER_AFTER_S - (time.monotonic() - ended_at)
if remaining > 0:
done, _ = await asyncio.wait({synthesis}, timeout=remaining)
if done:
return synthesis.result()
filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC]))
if filler:
self.send_audio(filler)
if timing is not None:
timing.filler = True
return await synthesis
async def synthesize(self, text: str) -> bytes:
return await cached_synthesize(self.models, text)
async def cached_synthesize(models: VoiceModels, text: str) -> bytes:
"""Синтез с кэшем на диске: первая реплика и филлеры звучат мгновенно,
а повторные фразы не синтезируются заново."""
key = hashlib.sha1(f"{models.synthesizer.speaker}|{TTS_RATE}|{text}".encode()).hexdigest()
path = CACHE / f"{key}.pcm"
if path.exists():
return path.read_bytes()
pcm = await models.synthesize(text)
if pcm:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(pcm)
return pcm
async def prefetch(models: VoiceModels, texts: list[str]) -> None:
"""Заранее синтезировать первую реплику и филлеры, пока курсант не снял трубку."""
for text in texts:
for sentence in sentences(text):
await cached_synthesize(models, sentence)

100
backend/app/voice/text.py Normal file
View 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

114
backend/app/voice/vad.py Normal file
View file

@ -0,0 +1,114 @@
"""Потоковый VAD: где оператор начал говорить и где закончил.
Silero VAD, окно 32 мс при 16 кГц, меньше миллисекунды на окно (docs/LATENCY.md).
Основная задержка не в модели, а в endpointing: фраза считается законченной
после 600 мс тишины. Ниже 400 мс режет на паузах внутри фразы («улица...
эээ... Ленина»), выше 800 мс ощущается как тормоз — не подбирать заново
(docs/arch/STACK.md).
"""
from collections import deque
from dataclasses import dataclass
from pathlib import Path
import numpy as np
RATE = 16_000
WINDOW = 512 # 32 мс
CONTEXT = 64 # хвост предыдущего окна: так модель обучалась, без него точность падает
WINDOW_MS = WINDOW * 1000 // RATE
@dataclass
class SpeechStarted:
"""Оператор заговорил. Если звонящий в этот момент говорит — это barge-in."""
@dataclass
class SpeechEnded:
audio: np.ndarray # float32, 16 кГц, с предзахватом начала фразы
class StreamingVad:
def __init__(
self,
model_path: Path,
*,
start_threshold: float = 0.5,
end_threshold: float = 0.35,
endpointing_ms: int = 600,
min_speech_ms: int = 64,
preroll_ms: int = 200,
max_utterance_ms: int = 20_000,
) -> None:
import onnxruntime as ort
options = ort.SessionOptions()
options.intra_op_num_threads = 1 # окно — доли миллисекунды, потоки только мешают
self._session = ort.InferenceSession(
str(model_path), sess_options=options, providers=["CPUExecutionProvider"]
)
# Порог начала выше порога конца: гистерезис, чтобы фраза не рвалась
# на каждом тихом слоге.
self.start_threshold = start_threshold
self.end_threshold = end_threshold
self.endpointing_ms = endpointing_ms
self.min_speech_ms = min_speech_ms
self.max_utterance_ms = max_utterance_ms
self._state = np.zeros((2, 1, 128), dtype=np.float32)
self._context = np.zeros(CONTEXT, dtype=np.float32)
self._pending = np.zeros(0, dtype=np.float32)
# Предзахват: первые звуки фразы звучат до того, как VAD уверится,
# что это речь. Без него «Назовите» распознаётся как «зовите».
self._preroll: deque[np.ndarray] = deque(maxlen=max(1, preroll_ms // WINDOW_MS))
self._speech: list[np.ndarray] = []
self._voiced_ms = 0
self._silence_ms = 0
self._in_speech = False
@property
def in_speech(self) -> bool:
return self._in_speech
def _probability(self, window: np.ndarray) -> float:
frame = np.concatenate([self._context, window])[None, :]
output, self._state = self._session.run(
None, {"input": frame, "state": self._state, "sr": np.array(RATE, dtype=np.int64)}
)
self._context = window[-CONTEXT:]
return float(output[0][0])
def push(self, pcm16: bytes) -> list[SpeechStarted | SpeechEnded]:
"""Кадр PCM16 16 кГц любой длины → события."""
samples = np.frombuffer(pcm16, dtype=np.int16).astype(np.float32) / 32768
self._pending = np.concatenate([self._pending, samples])
events: list[SpeechStarted | SpeechEnded] = []
while len(self._pending) >= WINDOW:
window, self._pending = self._pending[:WINDOW], self._pending[WINDOW:]
probability = self._probability(window)
if not self._in_speech:
self._preroll.append(window)
self._voiced_ms = self._voiced_ms + WINDOW_MS if probability >= self.start_threshold else 0
if self._voiced_ms >= self.min_speech_ms:
self._in_speech = True
self._speech = list(self._preroll)
self._silence_ms = 0
events.append(SpeechStarted())
continue
self._speech.append(window)
self._silence_ms = self._silence_ms + WINDOW_MS if probability < self.end_threshold else 0
too_long = len(self._speech) * WINDOW_MS >= self.max_utterance_ms
if self._silence_ms >= self.endpointing_ms or too_long:
# Хвост тишины распознаванию не нужен, но короткий запас оставляем:
# конец последнего слова бывает тише порога.
keep = len(self._speech) - max(0, self._silence_ms - 200) // WINDOW_MS
events.append(SpeechEnded(audio=np.concatenate(self._speech[:keep])))
self._in_speech = False
self._speech = []
self._voiced_ms = 0
self._preroll.clear()
return events

View file

@ -2,3 +2,9 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
# Голосовой контур грузит модели ~5 секунд на каждый старт приложения.
# Тесты каналов проверяют протокол, а не голос; голос — в test_voice_pipeline.py.
import os
os.environ.setdefault("VOICE_ENABLED", "false")

View file

@ -19,6 +19,9 @@ dependencies = [
"onnxruntime>=1.18",
"tokenizers>=0.19",
"numpy>=1.26",
# Числа словами перед синтезом: Silero молча выбрасывает цифры, и звонящий
# произносит «улица Ленина, дом… квартира…» без номеров.
"num2words>=0.5.13",
]
[project.optional-dependencies]

View file

@ -0,0 +1,192 @@
"""Голосовой контур на настоящих моделях: речь оператора → голос звонящего.
Речь оператора синтезируется мужским голосом Silero и подаётся кадрами по 20 мс
в реальном времени — как с микрофона. Синтетическая речь распознаётся легче живой:
задержка отсюда честная, а качество распознавания — оптимистичное.
Без моделей тест пропускается: make models.
"""
import asyncio
import importlib.util
import re
import time
from pathlib import Path
from uuid import uuid4
import numpy as np
import pytest
from app.domain.events import CallerUtterance, SttFinal, TtsBegin, TtsCancel, TtsEnd
ROOT = Path(__file__).resolve().parents[1]
MODELS = ROOT / "models"
LIBRARY = ROOT.parent / "scenarios"
# Пропуск, а не падение: и без файлов моделей, и без библиотек голоса (torch,
# onnx-asr) — их нет в `make test` и в контейнере, это отдельная группа зависимостей.
pytestmark = pytest.mark.skipif(
not all(importlib.util.find_spec(name) for name in ("torch", "onnx_asr"))
or not all(
(MODELS / path).exists()
for path in ("gigaam-v3-onnx/v3_rnnt_encoder.int8.onnx", "silero-vad/silero_vad.onnx",
"silero-tts/v5_ru.pt", "e5-small/model_quantized.onnx")
),
reason="голос не установлен: make models и uv sync --extra voice, запуск — make test-voice",
)
@pytest.fixture(scope="module")
def models():
from app.voice.models import Recognizer, Synthesizer, VoiceModels
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")
@pytest.fixture(scope="module")
def embedder():
from app.dialog.embeddings import E5Embedder
return E5Embedder(MODELS / "e5-small")
def operator_speech(models, text: str) -> bytes:
"""Реплика оператора мужским голосом, 16 кГц PCM16 — формат микрофона."""
spoken = models.synthesizer._model.apply_tts(text=text, speaker="aidar", sample_rate=24_000).numpy()
target = np.arange(0, len(spoken), 24_000 / 16_000)
audio = np.interp(target, np.arange(len(spoken)), spoken)
return (np.clip(audio, -1, 1) * 32767).astype(np.int16).tobytes()
def last_voiced_byte(pcm: bytes) -> int:
"""Конец речи по звуку, а не по последнему кадру: у синтезированной фразы
в конце своя тишина, и отсчёт от последнего кадра занижал бы задержку."""
samples = np.frombuffer(pcm, dtype=np.int16)
voiced = np.flatnonzero(np.abs(samples) > 1500)
return int(voiced[-1]) * 2 if len(voiced) else len(pcm)
async def stream(voice, pcm: bytes, trailing_silence_s: float = 1.0) -> float:
"""Подать звук кадрами по 20 мс в реальном времени. Возвращает момент,
когда был подан последний звучащий сэмпл, — конец реплики оператора."""
frame = 640
speech_end_byte = last_voiced_byte(pcm)
speech_ended = None
for offset in range(0, len(pcm), frame):
chunk = pcm[offset:offset + frame]
voice.feed(chunk.ljust(frame, b"\x00"))
if speech_ended is None and offset + frame > speech_end_byte:
speech_ended = time.monotonic()
await asyncio.sleep(0.02)
speech_ended = speech_ended or time.monotonic()
for _ in range(int(trailing_silence_s / 0.02)):
voice.feed(b"\x00" * frame)
await asyncio.sleep(0.02)
return speech_ended
def make_session(models, embedder):
from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
from app.domain.events import SessionMode
from app.scenarios.loader import load_file
from app.session.state import SessionState
from app.voice.pipeline import VoiceSession
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING,
)
state.slots = SlotMachine(scenario, embedder)
state.persona = PersonaState(scenario.persona)
state.caller = TemplateCaller()
sent: list[tuple[float, object]] = []
voice = VoiceSession(
session_id=state.session_id, state=state, models=models,
send_event=lambda e: sent.append((time.monotonic(), e)),
send_observer=lambda e: None,
send_audio=lambda pcm: sent.append((time.monotonic(), pcm)),
)
return voice, state, sent
async def test_operator_asks_address_caller_answers_by_voice(models, embedder, tmp_path, monkeypatch):
# Пустой кэш синтеза: иначе ответ звонящего берётся с диска от прошлого
# прогона, и в замере нет настоящего синтеза.
monkeypatch.setattr("app.voice.pipeline.CACHE", tmp_path)
voice, state, sent = make_session(models, embedder)
try:
speech_ended = await stream(voice, operator_speech(models, "Назовите адрес, пожалуйста."))
await wait_for(lambda: any(isinstance(e, TtsEnd) for _, e in sent), timeout=15)
finally:
await voice.close()
heard = next(e.text for _, e in sent if isinstance(e, SttFinal))
assert re.search(r"адрес", heard.lower()), f"распознано «{heard}»"
reply = next(e for _, e in sent if isinstance(e, CallerUtterance))
assert "Ленина" in reply.text, f"звонящий не назвал адрес: «{reply.text}»"
first_audio = next(t for t, e in sent if isinstance(e, bytes))
latency_ms = (first_audio - speech_ended) * 1000
timing = voice.timings[-1]
waited_ms = latency_ms - timing.speech_end_to_audio_ms
print(f"\nконец речи оператора → первый звук звонящего: {latency_ms:.0f} мс: "
f"ожидание паузы {waited_ms:.0f}, STT {timing.stt_ms:.0f}, звонящий {timing.caller_ms:.0f}, "
f"TTS {timing.tts_first_ms:.0f}{', с филлером' if timing.filler else ''}")
assert timing.tts_first_ms > 20, "синтез взят из кэша — замер нечестный"
# DoD: ≤ 1.5 с. Без LLM звонящий отвечает заготовкой — бюджет LLM здесь не расходуется.
assert latency_ms <= 1500, f"{latency_ms:.0f} мс"
begin = next(t for t, e in sent if isinstance(e, TtsBegin))
end = next(t for t, e in sent if isinstance(e, TtsEnd))
audio_s = sum(len(e) for _, e in sent if isinstance(e, bytes)) / 2 / 24_000
assert end - begin >= audio_s * 0.9, "tts.end пришёл раньше, чем звук мог доиграть"
async def test_operator_interrupts_caller(models, embedder):
"""Barge-in: оператор заговорил, пока звонящий говорит, — звук гасится."""
voice, state, sent = make_session(models, embedder)
try:
# Реплика по инициативе звонящего — так запускается первая фраза звонка.
speaking = voice.speak(
"Алло! Алло! Помогите! У нас горит балкон, дым идёт в квартиру, "
"жена с ребёнком в дальней комнате, быстрее приезжайте!", state.persona.mood)
await wait_for(lambda: any(isinstance(e, bytes) for _, e in sent), timeout=15)
await asyncio.sleep(0.5)
interrupt = operator_speech(models, "Подождите, успокойтесь.")
first_frame = time.monotonic()
streaming = asyncio.create_task(stream(voice, interrupt, trailing_silence_s=0.2))
await wait_for(lambda: any(isinstance(e, TtsCancel) for _, e in sent), timeout=5)
cancelled_at = next(t for t, e in sent if isinstance(e, TtsCancel))
await streaming
with pytest.raises(asyncio.CancelledError):
await speaking
finally:
await voice.close()
# Первые кадры синтезированной фразы — тишина до начала звука; меряем от
# первого кадра с речью, как её услышал бы VAD.
pcm = np.frombuffer(interrupt, dtype=np.int16)
onset_s = next(i for i in range(0, len(pcm), 320) if np.abs(pcm[i:i + 320]).max() > 1500) / 16_000
detection_ms = (cancelled_at - first_frame - onset_s) * 1000
print(f"\nперебивание: от начала речи оператора до tts.cancel {detection_ms:.0f} мс")
assert not any(isinstance(e, TtsEnd) for _, e in sent), "перебитая реплика не должна заканчиваться штатно"
assert detection_ms <= 300, f"{detection_ms:.0f} мс"
async def wait_for(predicate, timeout: float) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
await asyncio.sleep(0.01)
raise AssertionError("не дождались")

View file

@ -0,0 +1,53 @@
"""Нормализация текста перед синтезом: адрес должен прозвучать целиком."""
import re
from app.voice.text import normalize, sentences, speech_chunks
def test_address_digits_become_words():
spoken = normalize("улица Ленина, 14, квартира 47, 5-й этаж")
assert not re.search(r"\d", spoken), spoken
assert "четырнадцать" in spoken and "сорок семь" in spoken and "пятый" in spoken
def test_abbreviations_are_expanded():
assert normalize("ул. Ленина, д. 14, кв. 47") == "улица Ленина, дом четырнадцать, квартира сорок семь"
def test_ordinal_gender_follows_suffix():
assert normalize("5-я линия") == "пятая линия"
assert normalize("3-е окно") == "третье окно"
assert normalize("2-й подъезд") == "второй подъезд"
def test_building_letter_is_spoken():
spoken = normalize("дом 47B")
assert "сорок семь" in spoken and "бэ" in spoken and "B" not in spoken
def test_nothing_to_say_is_empty_not_an_error():
"""Пустая строка роняет Silero ValueError — нормализация отдаёт пустое,
и синтез просто не вызывается."""
assert normalize(" ") == ""
def test_reply_splits_by_sentence():
assert sentences("Алло! Горим! Улица Ленина, дом четырнадцать.") == [
"Алло!", "Горим!", "Улица Ленина, дом четырнадцать."
]
def test_long_address_is_chunked_for_a_fast_first_sound():
chunks = speech_chunks("Улица Ленина, 14, квартира 47, 5-й этаж! Быстрее!")
assert chunks[0] == "Улица Ленина, 14,", chunks
assert "".join(chunks).replace(" ", "") == "УлицаЛенина,14,квартира47,5-йэтаж!Быстрее!"
def test_short_sentences_are_not_chopped():
assert speech_chunks("Алло! Горим!") == ["Алло!", "Горим!"]
def test_house_number_stays_with_the_street():
chunks = speech_chunks("Я же сказал — улица Ленина, 14, квартира 47, 5-й этаж! Записывайте!")
assert chunks[0].endswith("Ленина, 14,"), chunks

20
backend/uv.lock generated
View file

@ -94,6 +94,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "docopt"
version = "0.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901, upload-time = "2014-06-16T11:18:57.406Z" }
[[package]]
name = "fastapi"
version = "0.141.1"
@ -281,6 +287,7 @@ dependencies = [
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "num2words" },
{ name = "numpy" },
{ name = "onnxruntime" },
{ name = "pydantic" },
@ -310,6 +317,7 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.29" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "num2words", specifier = ">=0.5.13" },
{ name = "numpy", specifier = ">=1.26" },
{ name = "onnx-asr", marker = "extra == 'voice'", specifier = ">=0.6" },
{ name = "onnxruntime", specifier = ">=1.18" },
@ -376,6 +384,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
]
[[package]]
name = "num2words"
version = "0.5.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docopt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/58/ad645bd38b4b648eb2fc2ba1b909398e54eb0cbb6a7dbd2b4953e38c9621/num2words-0.5.14.tar.gz", hash = "sha256:b066ec18e56b6616a3b38086b5747daafbaa8868b226a36127e0451c0cf379c6", size = 218213, upload-time = "2024-12-17T20:17:10.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/5b/545e9267a1cc080c8a1be2746113a063e34bcdd0f5173fd665a5c13cb234/num2words-0.5.14-py3-none-any.whl", hash = "sha256:1c8e5b00142fc2966fd8d685001e36c4a9911e070d1b120e1beb721fa1edb33d", size = 163525, upload-time = "2024-12-17T20:17:06.074Z" },
]
[[package]]
name = "numpy"
version = "2.4.6"