390 lines
14 KiB
Python
390 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
import time
|
|
from collections.abc import Iterable
|
|
from enum import Enum
|
|
|
|
from realtime_voice_service.core.vad import BaseVAD, SileroVADDetector
|
|
from realtime_voice_service.providers.base import BaseLLM, BaseSTT, BaseTTS, MockLLM, MockSTT, MockTTS
|
|
from realtime_voice_service.transports.base import BaseMediaTransport
|
|
|
|
|
|
LOGGER = logging.getLogger("uvicorn.error")
|
|
|
|
|
|
class SessionState(str, Enum):
|
|
LISTENING = "LISTENING"
|
|
USER_SPEAKING = "USER_SPEAKING"
|
|
ASSISTANT_THINKING = "ASSISTANT_THINKING"
|
|
ASSISTANT_SPEAKING = "ASSISTANT_SPEAKING"
|
|
|
|
|
|
class GenerationInterrupted(RuntimeError):
|
|
pass
|
|
|
|
|
|
class TextChunker:
|
|
_BOUNDARY_CHARS = ".!?\n"
|
|
|
|
def __init__(self) -> None:
|
|
self._buffer = ""
|
|
|
|
def feed(self, fragment: str) -> list[str]:
|
|
if not fragment:
|
|
return []
|
|
self._buffer += fragment
|
|
return self._extract_ready_chunks()
|
|
|
|
def flush(self) -> list[str]:
|
|
chunks = self._extract_ready_chunks()
|
|
tail = self._buffer.strip()
|
|
self._buffer = ""
|
|
if tail:
|
|
chunks.append(tail)
|
|
return chunks
|
|
|
|
def reset(self) -> None:
|
|
self._buffer = ""
|
|
|
|
def _extract_ready_chunks(self) -> list[str]:
|
|
chunks: list[str] = []
|
|
while True:
|
|
next_chunk = self._pop_next_chunk()
|
|
if next_chunk is None:
|
|
break
|
|
chunks.append(next_chunk)
|
|
return chunks
|
|
|
|
def _pop_next_chunk(self) -> str | None:
|
|
for index, char in enumerate(self._buffer):
|
|
if char not in self._BOUNDARY_CHARS:
|
|
continue
|
|
end_index = index + 1
|
|
while end_index < len(self._buffer) and self._buffer[end_index] in self._BOUNDARY_CHARS:
|
|
end_index += 1
|
|
while end_index < len(self._buffer) and self._buffer[end_index].isspace():
|
|
end_index += 1
|
|
chunk = self._buffer[:end_index].strip()
|
|
self._buffer = self._buffer[end_index:]
|
|
if chunk:
|
|
return chunk
|
|
return None
|
|
|
|
|
|
class CallSession:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
session_id: str,
|
|
transport: BaseMediaTransport,
|
|
vad: BaseVAD | None = None,
|
|
stt: BaseSTT | None = None,
|
|
llm: BaseLLM | None = None,
|
|
tts: BaseTTS | None = None,
|
|
) -> None:
|
|
self.session_id = session_id
|
|
self.transport = transport
|
|
self.state = SessionState.LISTENING
|
|
self.generation_epoch = 0
|
|
self.interruptions: list[str] = []
|
|
self.last_latency_ms: dict[str, int] = {}
|
|
self._vad = vad or SileroVADDetector(sample_rate_hz=transport.sample_rate_hz)
|
|
self._stt = stt or MockSTT(sample_rate_hz=transport.sample_rate_hz)
|
|
self._llm = llm or MockLLM()
|
|
self._tts = tts or MockTTS(sample_rate_hz=transport.sample_rate_hz)
|
|
self._conversation: list[tuple[str, str]] = []
|
|
self._assistant_task: asyncio.Task[None] | None = None
|
|
self._sentence_queue: asyncio.Queue[str | None] | None = None
|
|
self._closed = False
|
|
|
|
@property
|
|
def conversation(self) -> tuple[tuple[str, str], ...]:
|
|
return tuple(self._conversation)
|
|
|
|
async def run(self) -> None:
|
|
if self._assistant_task is not None:
|
|
raise RuntimeError("CallSession.run() can only be called once per session")
|
|
try:
|
|
await self.media_loop()
|
|
finally:
|
|
await self.stop()
|
|
|
|
async def media_loop(self) -> None:
|
|
while not self._closed:
|
|
audio_chunk = await self.transport.receive_audio()
|
|
if audio_chunk is None:
|
|
LOGGER.info("realtime session %s transport closed", self.session_id)
|
|
break
|
|
if not audio_chunk:
|
|
continue
|
|
|
|
vad_result = self._vad.feed(audio_chunk)
|
|
|
|
if vad_result.is_speech and self.state != SessionState.USER_SPEAKING:
|
|
if self.state in {SessionState.ASSISTANT_THINKING, SessionState.ASSISTANT_SPEAKING}:
|
|
self.interrupt("barge-in")
|
|
self._set_state(
|
|
SessionState.USER_SPEAKING,
|
|
reason=f"speech detected prob={vad_result.speech_probability:.3f}",
|
|
)
|
|
|
|
if vad_result.speech_ended and vad_result.utterance_audio:
|
|
speech_end_monotonic = time.perf_counter()
|
|
self._set_state(SessionState.ASSISTANT_THINKING, reason="speech end detected")
|
|
self._start_assistant_turn(
|
|
epoch=self.generation_epoch,
|
|
utterance_audio=vad_result.utterance_audio,
|
|
speech_end_monotonic=speech_end_monotonic,
|
|
)
|
|
|
|
def interrupt(self, reason: str = "interrupt") -> int:
|
|
self.generation_epoch += 1
|
|
self.interruptions.append(reason)
|
|
self._clear_sentence_queue()
|
|
if self._assistant_task is not None and not self._assistant_task.done():
|
|
self._assistant_task.cancel()
|
|
LOGGER.info(
|
|
"realtime session %s interrupted: epoch=%s reason=%s",
|
|
self.session_id,
|
|
self.generation_epoch,
|
|
reason,
|
|
)
|
|
return self.generation_epoch
|
|
|
|
async def stop(self) -> None:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
self._vad.reset()
|
|
self._clear_sentence_queue()
|
|
if self._assistant_task is not None and not self._assistant_task.done():
|
|
self._assistant_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await self._assistant_task
|
|
await self.transport.close()
|
|
|
|
def _start_assistant_turn(
|
|
self,
|
|
*,
|
|
epoch: int,
|
|
utterance_audio: bytes,
|
|
speech_end_monotonic: float,
|
|
) -> None:
|
|
if self._assistant_task is not None and not self._assistant_task.done():
|
|
self._assistant_task.cancel()
|
|
self._assistant_task = asyncio.create_task(
|
|
self._run_assistant_turn(
|
|
epoch=epoch,
|
|
utterance_audio=utterance_audio,
|
|
speech_end_monotonic=speech_end_monotonic,
|
|
),
|
|
name=f"{self.session_id}-assistant-{epoch}",
|
|
)
|
|
|
|
async def _run_assistant_turn(
|
|
self,
|
|
*,
|
|
epoch: int,
|
|
utterance_audio: bytes,
|
|
speech_end_monotonic: float,
|
|
) -> None:
|
|
sentence_queue: asyncio.Queue[str | None] | None = None
|
|
playback_task: asyncio.Task[None] | None = None
|
|
assistant_fragments: list[str] = []
|
|
try:
|
|
transcript = await self._stt.transcribe(utterance_audio)
|
|
self._ensure_generation(epoch)
|
|
self._log_latency(
|
|
"stt_latency",
|
|
speech_end_monotonic,
|
|
epoch=epoch,
|
|
message="speech end -> transcript ready",
|
|
)
|
|
transcript = transcript.strip()
|
|
if not transcript:
|
|
LOGGER.info("realtime session %s produced empty transcript", self.session_id)
|
|
self._set_state(SessionState.LISTENING, reason="empty transcript")
|
|
return
|
|
|
|
self._conversation.append(("user", transcript))
|
|
sentence_queue = asyncio.Queue()
|
|
self._sentence_queue = sentence_queue
|
|
playback_task = asyncio.create_task(
|
|
self._stream_tts_pipeline(epoch=epoch, sentence_queue=sentence_queue),
|
|
name=f"{self.session_id}-playback-{epoch}",
|
|
)
|
|
llm_started_monotonic = time.perf_counter()
|
|
chunker = TextChunker()
|
|
first_token_seen = False
|
|
async for token in self._llm.generate_stream(transcript, list(self._conversation)):
|
|
self._ensure_generation(epoch)
|
|
if not token:
|
|
continue
|
|
if not first_token_seen:
|
|
first_token_seen = True
|
|
self._log_latency(
|
|
"ttft",
|
|
llm_started_monotonic,
|
|
epoch=epoch,
|
|
message="llm request -> first token",
|
|
)
|
|
assistant_fragments.append(token)
|
|
await self._enqueue_chunks(
|
|
epoch=epoch,
|
|
sentence_queue=sentence_queue,
|
|
chunks=chunker.feed(token),
|
|
)
|
|
|
|
assistant_text = "".join(assistant_fragments).strip()
|
|
if not assistant_text:
|
|
await self._finish_sentence_queue(sentence_queue)
|
|
LOGGER.warning("realtime session %s llm produced no text", self.session_id)
|
|
self._set_state(SessionState.LISTENING, reason="empty llm response")
|
|
return
|
|
|
|
await self._enqueue_chunks(
|
|
epoch=epoch,
|
|
sentence_queue=sentence_queue,
|
|
chunks=chunker.flush(),
|
|
)
|
|
await self._finish_sentence_queue(sentence_queue)
|
|
if playback_task is not None:
|
|
await playback_task
|
|
self._ensure_generation(epoch)
|
|
self._conversation.append(("assistant", assistant_text))
|
|
self._set_state(SessionState.LISTENING, reason="assistant turn completed")
|
|
except GenerationInterrupted:
|
|
LOGGER.info(
|
|
"realtime session %s ignored stale generation %s",
|
|
self.session_id,
|
|
epoch,
|
|
)
|
|
except asyncio.CancelledError:
|
|
LOGGER.info(
|
|
"realtime session %s cancelled generation %s",
|
|
self.session_id,
|
|
epoch,
|
|
)
|
|
raise
|
|
except Exception:
|
|
LOGGER.exception(
|
|
"realtime session %s generation %s failed",
|
|
self.session_id,
|
|
epoch,
|
|
)
|
|
if epoch == self.generation_epoch and not self._closed:
|
|
self._set_state(SessionState.LISTENING, reason="assistant generation failed")
|
|
finally:
|
|
self._clear_sentence_queue(sentence_queue)
|
|
if playback_task is not None and not playback_task.done():
|
|
playback_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await playback_task
|
|
current_task = asyncio.current_task()
|
|
if self._assistant_task is current_task:
|
|
self._assistant_task = None
|
|
|
|
def _ensure_generation(self, epoch: int) -> None:
|
|
if self._closed or epoch != self.generation_epoch:
|
|
raise GenerationInterrupted(f"stale generation {epoch}")
|
|
|
|
def _log_latency(
|
|
self,
|
|
metric_name: str,
|
|
started_monotonic: float,
|
|
*,
|
|
epoch: int,
|
|
message: str,
|
|
) -> None:
|
|
latency_ms = max(int((time.perf_counter() - started_monotonic) * 1000.0), 0)
|
|
self.last_latency_ms[metric_name] = latency_ms
|
|
LOGGER.info(
|
|
"realtime session %s %s=%sms epoch=%s (%s)",
|
|
self.session_id,
|
|
metric_name,
|
|
latency_ms,
|
|
epoch,
|
|
message,
|
|
)
|
|
|
|
async def _enqueue_chunks(
|
|
self,
|
|
*,
|
|
epoch: int,
|
|
sentence_queue: asyncio.Queue[str | None],
|
|
chunks: Iterable[str],
|
|
) -> None:
|
|
for chunk in chunks:
|
|
self._ensure_generation(epoch)
|
|
normalized = chunk.strip()
|
|
if not normalized:
|
|
continue
|
|
await sentence_queue.put(normalized)
|
|
|
|
async def _finish_sentence_queue(self, sentence_queue: asyncio.Queue[str | None]) -> None:
|
|
await sentence_queue.put(None)
|
|
|
|
async def _stream_tts_pipeline(
|
|
self,
|
|
*,
|
|
epoch: int,
|
|
sentence_queue: asyncio.Queue[str | None],
|
|
) -> None:
|
|
first_audio_seen = False
|
|
first_tts_started_monotonic: float | None = None
|
|
while True:
|
|
self._ensure_generation(epoch)
|
|
sentence = await sentence_queue.get()
|
|
if sentence is None:
|
|
return
|
|
normalized = sentence.strip()
|
|
if not normalized:
|
|
continue
|
|
if first_tts_started_monotonic is None:
|
|
first_tts_started_monotonic = time.perf_counter()
|
|
async for audio_chunk in self._tts.synthesize_stream(normalized):
|
|
self._ensure_generation(epoch)
|
|
if not audio_chunk:
|
|
continue
|
|
if not first_audio_seen:
|
|
first_audio_seen = True
|
|
self._log_latency(
|
|
"ttfa",
|
|
first_tts_started_monotonic or time.perf_counter(),
|
|
epoch=epoch,
|
|
message="tts request -> first audio",
|
|
)
|
|
self._set_state(SessionState.ASSISTANT_SPEAKING, reason="assistant playback started")
|
|
await self.transport.send_audio(audio_chunk)
|
|
|
|
def _clear_sentence_queue(self, sentence_queue: asyncio.Queue[str | None] | None = None) -> None:
|
|
queue = sentence_queue if sentence_queue is not None else self._sentence_queue
|
|
if queue is None:
|
|
return
|
|
while True:
|
|
try:
|
|
queue.get_nowait()
|
|
except asyncio.QueueEmpty:
|
|
break
|
|
with contextlib.suppress(asyncio.QueueFull):
|
|
queue.put_nowait(None)
|
|
if queue is self._sentence_queue:
|
|
self._sentence_queue = None
|
|
|
|
def _set_state(self, state: SessionState, *, reason: str | None = None) -> None:
|
|
if self.state == state:
|
|
return
|
|
previous_state = self.state
|
|
self.state = state
|
|
suffix = f" ({reason})" if reason else ""
|
|
LOGGER.info(
|
|
"realtime session %s state %s -> %s%s",
|
|
self.session_id,
|
|
previous_state.value,
|
|
state.value,
|
|
suffix,
|
|
)
|