Files
esp32-server/app/xiaozhi/session.py
kunthawat e96498b67e fix: resolve fresh-eyes review criticals + majors on the audio path
- C1: stop feeding the unconsumed barge-in TTSQueue in the reply path (deadlocked after 64 chunks on a real long reply)
- C2: encode reply Opus at the TTS/codec sample rate (24 kHz), not hardcoded 16 kHz; encoder now rate-generic
- M3: bound the mic PCM buffer to the 60 s max utterance
- M4: tts_sentence_start per speakable sentence, not per LLM token
- Minor: send tts.stop on a failed turn; constant-time token compare
- Sec: untrack + gitignore config.yaml; add config.example.yaml with placeholder secrets
- Tests: 23 pass (2 new regressions: long-reply deadlock, rate-generic encoder)
2026-10-03 21:55:32 +07:00

107 lines
4.2 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()
# Bound the mic buffer to the 60 s max utterance so a quiet room
# that never latches speech cannot grow memory without limit
# (review Major 3). 60 s @ 16 kHz mono 16-bit = 1.92 MB.
self._buffer = PCMBuffer(max_bytes=60 * 16000 * 2)
# -- 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
# Display text streams per LLM token (REQ: live transcript).
yield ("tts_text", chunk)
# Stream TTS as the answer arrives (REQ-006): each speakable
# sentence (chunk_text piece) is announced once as
# ``tts_sentence`` — that is the unit the firmware displays as a
# sentence (review Major 4: NOT one per LLM token).
for piece in chunk_text(chunk):
yield ("tts_sentence", piece)
for audio in await self._synthesize(piece):
self.logger.first_audio_packet()
yield ("audio", audio)
self.client.session().push(result.text, full)
async def _synthesize(self, text: str) -> list[bytes]:
"""Synthesize one speakable sentence; collect its audio chunks.
The barge-in TTSQueue is deliberately NOT fed here: this PoC streams
the reply synchronously, and putting into the queue without a consumer
blocks forever once 64 chunks accumulated on a real (long) reply
(review Critical 1). ``barge_in()`` stays the documented interrupt.
"""
out: list[bytes] = []
async for audio in self.tts.synthesize(text):
out.append(audio)
return out