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
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("не дождались")
|
||||
Loading…
Reference in a new issue