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 → STT → звонящий → TTS, с перебиванием.
|
|
|
|
|
|
|
|
|
|
|
|
Задача ответа живёт, пока у курсанта **доигрывает звук**, а не только пока идёт
|
|
|
|
|
|
синтез. Поэтому barge-in — это просто отмена этой задачи: одним движением гасятся
|
|
|
|
|
|
распознавание, реплика звонящего, синтез и ожидание конца воспроизведения
|
|
|
|
|
|
(docs/arch/BACKEND.md), а `tts.end` приходит тогда, когда звонящий действительно
|
|
|
|
|
|
замолчал.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import time
|
2026-09-26 22:48:24 +00:00
|
|
|
|
from collections.abc import Awaitable, Callable
|
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 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]
|
2026-09-26 22:48:24 +00:00
|
|
|
|
#: Сохранить реплику одной операцией хранилища; без него реплика живёт в памяти.
|
|
|
|
|
|
persist: Callable[[object], Awaitable[None]] | None = 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
|
|
|
|
|
|
|
|
|
|
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))
|
2026-09-26 22:48:24 +00:00
|
|
|
|
if self.persist is not None:
|
|
|
|
|
|
await self.persist(entry)
|
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
|
|
|
|
|
|
|
|
|
|
started = time.monotonic()
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
line = await self._caller_line(text)
|
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
|
|
|
|
timing.caller_ms = (time.monotonic() - started) * 1000
|
|
|
|
|
|
await self.say(line.text, line.mood, ended_at=ended_at, timing=timing)
|
|
|
|
|
|
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
async def _caller_line(self, text: str):
|
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
|
|
|
|
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)
|
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.
Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.
Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.
Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.
Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
|
|
|
|
return await state.caller.reply(turn, state.persona, state.slots)
|
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
|
|
|
|
|
|
|
|
|
|
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))
|
2026-09-26 22:48:24 +00:00
|
|
|
|
if self.persist is not None:
|
|
|
|
|
|
await self.persist(entry)
|
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.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)
|