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
|
|
|
|
"""Потоковый 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
|
|
|
|
|
|
|
2026-09-27 18:06:49 +03:00
|
|
|
|
def reset(self) -> None:
|
|
|
|
|
|
"""Не склеивать фрагменты речи до и после паузы занятия."""
|
|
|
|
|
|
self._state.fill(0)
|
|
|
|
|
|
self._context.fill(0)
|
|
|
|
|
|
self._pending = np.zeros(0, dtype=np.float32)
|
|
|
|
|
|
self._preroll.clear()
|
|
|
|
|
|
self._speech.clear()
|
|
|
|
|
|
self._voiced_ms = 0
|
|
|
|
|
|
self._silence_ms = 0
|
|
|
|
|
|
self._in_speech = False
|
|
|
|
|
|
|
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
|
|
|
|
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
|