Files
esp32-server/app/xiaozhi/session.py
Macky b8950b38a4 feat: Phase 1 testable bridge skeleton (18 tests green)
Xiaozhi WebSocket endpoint with handshake/auth, mock STT/TTS/Hermes
backends, Thai chunker, barge-in queue, latency logger, GPU planner,
voice-profile guard (reasoning=none, session_search only).

CON-002: no GPU/audio libs loaded at import. MUST-NOT-001: all model
names/tokens from config.
2026-10-03 11:45:31 +07:00

98 lines
3.5 KiB
Python

"""Conversation session — wires audio → STT → voice profile → TTS (REQ-001..008).
The session is the state machine for one connected device. It owns:
the PCM buffer, VAD end-of-speech, the TTS queue (for barge-in), and the
latency logger. It exposes :meth:`on_user_speech` which runs one spoken turn
end-to-end and yields assistant text chunks — the single seam the websocket
endpoint drives.
Cross-session recall (REQ-008): the session holds a ``session_search_fn``
injected by the caller (the Hermes layer) so "when did we do X?" questions
can pull a real session snippet, without the session module importing the
search implementation.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import AsyncIterator, Awaitable, Callable
from ..audio.buffer import PCMBuffer
from ..audio.vad import VAD
from ..metrics.latency import LatencyLogger
from ..stt import STTEngine
from ..tts import TTSQueue, TTSEngine, chunk_text
from ..hermes import HermesClient
from . import messages
@dataclass
class Session:
client: HermesClient
stt: STTEngine
tts: TTSEngine
vad: VAD
logger: LatencyLogger
session_search_fn: Callable[[str], Awaitable[str]] | None = None
_queue: TTSQueue | None = None
_buffer: PCMBuffer | None = None
def __post_init__(self) -> None:
self._queue = TTSQueue()
self._buffer = PCMBuffer()
# -- audio in (REQ-002) -------------------------------------------------
def feed_audio(self, pcm: bytes) -> None:
self._buffer.feed(pcm)
def end_of_speech(self) -> bytes:
"""Flush the utterance PCM when the VAD reports end-of-speech."""
self.logger.speech_end()
return self._buffer.get()
# -- barge-in (REQ-007) -------------------------------------------------
def barge_in(self) -> int:
"""User interrupted: cancel the in-flight TTS. Returns dropped chunks."""
assert self._queue is not None
return self._queue.cancel()
# -- one spoken turn (REQ-001..008) -------------------------------------
async def run_turn(
self, user_pcm: bytes
) -> AsyncIterator[tuple[str, str | bytes]]:
"""Run one turn; yield ``(kind, payload)`` events.
* ``("stt", str)`` — final transcript.
* ``("tts_text", str)`` — assistant text chunk (for display).
* ``("audio", bytes)`` — PCM chunk (for playback).
"""
self.logger.speech_start()
result = await self.stt.transcribe(user_pcm)
self.logger.stt_final()
yield ("stt", result.text)
self.logger.hermes_request()
full = ""
async for chunk in self.client.respond(result.text):
self.logger.qwen_first_token() # first chunk is the first token
full += chunk
yield ("tts_text", chunk)
# Stream TTS as the answer arrives (REQ-006): speak each speakable
# chunk before the answer is complete.
for audio in await self._tts_chunks(chunk):
self.logger.first_audio_packet()
yield ("audio", audio)
self.client.session().push(result.text, full)
async def _tts_chunks(self, text: str) -> list[bytes]:
"""Chunk the text and synthesize; collect audio (barge-in via queue)."""
from ..tts import chunk_text
out: list[bytes] = []
q = self._queue
for piece in chunk_text(text):
async for audio in self.tts.synthesize(piece):
await q.put(audio)
out.append(audio)
return out