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