Files
livekit--agents/tests/test_plugin_xai_tts.py
2026-07-13 13:39:38 +08:00

121 lines
3.8 KiB
Python

"""Unit tests for xAI TTS websocket behavior."""
from __future__ import annotations
import asyncio
import base64
import json
from dataclasses import replace
from types import SimpleNamespace
from typing import Any
import aiohttp
import pytest
from livekit.agents import APIConnectOptions
from livekit.plugins.xai import tts as xai_tts
pytestmark = pytest.mark.plugin("xai")
class _FakeWebSocket:
def __init__(self, audio: bytes = b"audio") -> None:
self._audio = audio
self._messages: asyncio.Queue[Any] = asyncio.Queue()
self.close_code: int | None = None
self.sent: list[dict[str, Any]] = []
self.closed = False
async def send_str(self, data: str) -> None:
packet = json.loads(data)
self.sent.append(packet)
if packet["type"] == "text.done":
await self._messages.put(
SimpleNamespace(
type=aiohttp.WSMsgType.TEXT,
data=json.dumps(
{
"type": "audio.delta",
"delta": base64.b64encode(self._audio).decode("ascii"),
}
),
)
)
await self._messages.put(
SimpleNamespace(
type=aiohttp.WSMsgType.TEXT,
data=json.dumps({"type": "audio.done"}),
)
)
async def receive(self) -> Any:
return await self._messages.get()
async def close(self) -> None:
self.closed = True
class _FakeEmitter:
def __init__(self) -> None:
self.started_segments: list[str] = []
self.ended_segments = 0
self.audio_chunks: list[bytes] = []
def start_segment(self, *, segment_id: str) -> None:
self.started_segments.append(segment_id)
def push(self, audio: bytes) -> None:
self.audio_chunks.append(audio)
def end_segment(self) -> None:
self.ended_segments += 1
def _new_word_stream(tts: xai_tts.TTS, text: str):
word_stream = tts._opts.tokenizer.stream() # pyright: ignore[reportPrivateUsage]
word_stream.push_text(text)
word_stream.end_input()
return word_stream
def _new_synthesize_stream(tts: xai_tts.TTS) -> xai_tts.SynthesizeStream:
stream = object.__new__(xai_tts.SynthesizeStream)
stream._tts = tts # pyright: ignore[reportPrivateUsage]
stream._opts = replace(tts._opts) # pyright: ignore[reportPrivateUsage]
stream._conn_options = APIConnectOptions(max_retry=0, timeout=1.0) # pyright: ignore[reportPrivateUsage]
stream._started_time = 0.0 # pyright: ignore[reportPrivateUsage]
stream._acquire_time = 0.0 # pyright: ignore[reportPrivateUsage]
stream._connection_reused = False # pyright: ignore[reportPrivateUsage]
return stream
@pytest.mark.asyncio
async def test_streaming_segments_reuse_websocket_connection(monkeypatch: pytest.MonkeyPatch):
tts = xai_tts.TTS(api_key="test-key")
stream = _new_synthesize_stream(tts)
emitter = _FakeEmitter()
websockets: list[_FakeWebSocket] = []
async def connect_ws(*_args: object, **_kwargs: object) -> _FakeWebSocket:
ws = _FakeWebSocket()
websockets.append(ws)
return ws
async def close_ws(ws: _FakeWebSocket) -> None:
await ws.close()
monkeypatch.setattr(tts, "_connect_ws", connect_ws)
monkeypatch.setattr(tts, "_close_ws", close_ws)
await stream._run_ws(_new_word_stream(tts, "first"), emitter) # pyright: ignore[reportPrivateUsage]
await stream._run_ws(_new_word_stream(tts, "second"), emitter) # pyright: ignore[reportPrivateUsage]
assert len(websockets) == 1
assert websockets[0].closed is False
assert emitter.audio_chunks == [b"audio", b"audio"]
assert emitter.ended_segments == 2
await tts.aclose()
assert websockets[0].closed is True