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:
parent
dcdec56779
commit
456f3cd34d
18 changed files with 933 additions and 15 deletions
11
Makefile
11
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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_* принимаются тоже — так их выставляет командный сниппет.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) кадры только считаются —
|
||||
# этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера.
|
||||
|
|
|
|||
0
backend/app/voice/__init__.py
Normal file
0
backend/app/voice/__init__.py
Normal file
121
backend/app/voice/models.py
Normal file
121
backend/app/voice/models.py
Normal 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
|
||||
248
backend/app/voice/pipeline.py
Normal file
248
backend/app/voice/pipeline.py
Normal 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
100
backend/app/voice/text.py
Normal 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
114
backend/app/voice/vad.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ dependencies = [
|
|||
"onnxruntime>=1.18",
|
||||
"tokenizers>=0.19",
|
||||
"numpy>=1.26",
|
||||
# Числа словами перед синтезом: Silero молча выбрасывает цифры, и звонящий
|
||||
# произносит «улица Ленина, дом… квартира…» без номеров.
|
||||
"num2words>=0.5.13",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
192
backend/tests/test_voice_pipeline.py
Normal file
192
backend/tests/test_voice_pipeline.py
Normal 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("не дождались")
|
||||
53
backend/tests/test_voice_text.py
Normal file
53
backend/tests/test_voice_text.py
Normal 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
20
backend/uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue