From 456f3cd34ddcdc4f9e440d23a41fe8cf65392ddd Mon Sep 17 00:00:00 2001 From: Ivan Gerasimov Date: Thu, 17 Sep 2026 15:04:17 +0300 Subject: [PATCH] =?UTF-8?q?lct-06:=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=20VAD?= =?UTF-8?q?=20=E2=86=92=20STT=20=E2=86=92=20=D0=B7=D0=B2=D0=BE=D0=BD=D1=8F?= =?UTF-8?q?=D1=89=D0=B8=D0=B9=20=E2=86=92=20TTS,=20=D1=81=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B1=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Контур замкнут без 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. --- Makefile | 11 +- backend/Dockerfile | 10 +- backend/app/api/ws/call.py | 39 ++++- backend/app/api/ws/control.py | 12 ++ backend/app/config.py | 2 + backend/app/main.py | 8 +- backend/app/session/hub.py | 4 +- backend/app/session/state.py | 5 + backend/app/voice/__init__.py | 0 backend/app/voice/models.py | 121 +++++++++++++ backend/app/voice/pipeline.py | 248 +++++++++++++++++++++++++++ backend/app/voice/text.py | 100 +++++++++++ backend/app/voice/vad.py | 114 ++++++++++++ backend/conftest.py | 6 + backend/pyproject.toml | 3 + backend/tests/test_voice_pipeline.py | 192 +++++++++++++++++++++ backend/tests/test_voice_text.py | 53 ++++++ backend/uv.lock | 20 +++ 18 files changed, 933 insertions(+), 15 deletions(-) create mode 100644 backend/app/voice/__init__.py create mode 100644 backend/app/voice/models.py create mode 100644 backend/app/voice/pipeline.py create mode 100644 backend/app/voice/text.py create mode 100644 backend/app/voice/vad.py create mode 100644 backend/tests/test_voice_pipeline.py create mode 100644 backend/tests/test_voice_text.py diff --git a/Makefile b/Makefile index df58942..b5e8273 100644 --- a/Makefile +++ b/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 diff --git a/backend/Dockerfile b/backend/Dockerfile index c62da5b..a498151 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index 6312758..2041019 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -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}") diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py index c4a651f..752352c 100644 --- a/backend/app/api/ws/control.py +++ b/backend/app/api/ws/control.py @@ -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) diff --git a/backend/app/config.py b/backend/app/config.py index d27e0ba..cde44c1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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_* принимаются тоже — так их выставляет командный сниппет. diff --git a/backend/app/main.py b/backend/app/main.py index e958ee4..fd02906 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py index 6e99bf7..e80ad21 100644 --- a/backend/app/session/hub.py +++ b/backend/app/session/hub.py @@ -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: diff --git a/backend/app/session/state.py b/backend/app/session/state.py index a2fdf90..e52bd2f 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -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) кадры только считаются — # этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера. diff --git a/backend/app/voice/__init__.py b/backend/app/voice/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/voice/models.py b/backend/app/voice/models.py new file mode 100644 index 0000000..1d34a82 --- /dev/null +++ b/backend/app/voice/models.py @@ -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 diff --git a/backend/app/voice/pipeline.py b/backend/app/voice/pipeline.py new file mode 100644 index 0000000..69dcc3e --- /dev/null +++ b/backend/app/voice/pipeline.py @@ -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) diff --git a/backend/app/voice/text.py b/backend/app/voice/text.py new file mode 100644 index 0000000..82adbf7 --- /dev/null +++ b/backend/app/voice/text.py @@ -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 diff --git a/backend/app/voice/vad.py b/backend/app/voice/vad.py new file mode 100644 index 0000000..a5561f1 --- /dev/null +++ b/backend/app/voice/vad.py @@ -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 diff --git a/backend/conftest.py b/backend/conftest.py index 1da1dcf..8b1aa90 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -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") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f456c47..dc826dd 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,6 +19,9 @@ dependencies = [ "onnxruntime>=1.18", "tokenizers>=0.19", "numpy>=1.26", + # Числа словами перед синтезом: Silero молча выбрасывает цифры, и звонящий + # произносит «улица Ленина, дом… квартира…» без номеров. + "num2words>=0.5.13", ] [project.optional-dependencies] diff --git a/backend/tests/test_voice_pipeline.py b/backend/tests/test_voice_pipeline.py new file mode 100644 index 0000000..7effa4b --- /dev/null +++ b/backend/tests/test_voice_pipeline.py @@ -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("не дождались") diff --git a/backend/tests/test_voice_text.py b/backend/tests/test_voice_text.py new file mode 100644 index 0000000..0ee0079 --- /dev/null +++ b/backend/tests/test_voice_text.py @@ -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 diff --git a/backend/uv.lock b/backend/uv.lock index 896a217..48bdaa5 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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"