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
|
|
@ -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
|
||||
Loading…
Reference in a new issue