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

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