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

258 lines
7.7 KiB
Python

"""Unit tests for ElevenLabs TTS plugin configuration and websocket behavior."""
import asyncio
import base64
import json
from types import SimpleNamespace
import aiohttp
import pytest
from livekit.plugins.elevenlabs import tts as elevenlabs_tts
pytestmark = pytest.mark.plugin("elevenlabs")
class _FakeWebSocket:
def __init__(self, messages: list[object]) -> None:
self._messages = messages
self.closed = False
async def receive(self) -> object:
if self._messages:
return self._messages.pop(0)
return SimpleNamespace(type=aiohttp.WSMsgType.CLOSE, data="")
async def close(self) -> None:
self.closed = True
class _FakeEmitter:
def __init__(self) -> None:
self.audio_chunks: list[bytes] = []
self.timed_transcript_pushes = 0
def push(self, audio: bytes) -> None:
self.audio_chunks.append(audio)
def push_timed_transcript(self, _timed_words: object) -> None:
self.timed_transcript_pushes += 1
class _FakeStream:
def __init__(self) -> None:
self._text_buffer = ""
self._start_times_ms: list[int] = []
self._durations_ms: list[int] = []
class _FakeConnection:
def __init__(self, context_id: str, messages: list[object]) -> None:
self._closed = False
self._ws = _FakeWebSocket(messages)
self._is_current = True
self._active_contexts = {context_id}
self.emitter = _FakeEmitter()
self.waiter: asyncio.Future[None] = asyncio.get_event_loop().create_future()
self._context_data = {
context_id: elevenlabs_tts._StreamData(
emitter=self.emitter,
stream=_FakeStream(),
waiter=self.waiter,
)
}
self.preferred_alignment = "normalized"
def _cleanup_context(self, context_id: str) -> None:
ctx = self._context_data.pop(context_id, None)
if ctx and ctx.timeout_timer:
ctx.timeout_timer.cancel()
self._active_contexts.discard(context_id)
async def aclose(self) -> None:
self._closed = True
await self._ws.close()
def _websocket_text_message(payload: dict[str, object]) -> object:
return SimpleNamespace(type=aiohttp.WSMsgType.TEXT, data=json.dumps(payload))
def test_auto_mode_defaults_to_true_without_chunk_length_schedule() -> None:
tts = elevenlabs_tts.TTS(api_key="test-key")
assert tts._opts.auto_mode is True
def test_auto_mode_defaults_to_false_with_chunk_length_schedule() -> None:
tts = elevenlabs_tts.TTS(api_key="test-key", chunk_length_schedule=[120, 160, 250, 290])
assert tts._opts.auto_mode is False
def test_auto_mode_respects_explicit_value_with_chunk_length_schedule() -> None:
tts = elevenlabs_tts.TTS(
api_key="test-key",
chunk_length_schedule=[120, 160, 250, 290],
auto_mode=True,
)
assert tts._opts.auto_mode is True
def test_build_context_init_packet_includes_generation_config() -> None:
tts = elevenlabs_tts.TTS(api_key="test-key", chunk_length_schedule=[80, 120], auto_mode=False)
packet = elevenlabs_tts._build_context_init_packet( # pyright: ignore[reportPrivateUsage]
tts._opts, context_id="ctx-1"
)
assert packet["text"] == " "
assert packet["context_id"] == "ctx-1"
assert packet["generation_config"] == {"chunk_length_schedule": [80, 120]}
def test_build_context_init_packet_omits_generation_config_when_not_set() -> None:
tts = elevenlabs_tts.TTS(api_key="test-key")
packet = elevenlabs_tts._build_context_init_packet( # pyright: ignore[reportPrivateUsage]
tts._opts, context_id="ctx-2"
)
assert "generation_config" not in packet
def test_build_context_init_packet_includes_pronunciation_dictionaries() -> None:
tts = elevenlabs_tts.TTS(
api_key="test-key",
pronunciation_dictionary_locators=[
elevenlabs_tts.PronunciationDictionaryLocator(
pronunciation_dictionary_id="dict-1",
version_id="v1",
)
],
)
packet = elevenlabs_tts._build_context_init_packet( # pyright: ignore[reportPrivateUsage]
tts._opts, context_id="ctx-3"
)
assert packet["pronunciation_dictionary_locators"] == [
{
"pronunciation_dictionary_id": "dict-1",
"version_id": "v1",
}
]
@pytest.mark.asyncio
async def test_recv_loop_accepts_snake_case_context_id() -> None:
context_id = "ctx_123"
audio_chunk = b"hello-audio"
connection = _FakeConnection(
context_id,
[
_websocket_text_message(
{
"context_id": context_id,
"audio": base64.b64encode(audio_chunk).decode("ascii"),
"isFinal": True,
}
),
],
)
await elevenlabs_tts._Connection._recv_loop(connection)
assert connection.emitter.audio_chunks == [audio_chunk]
assert connection.waiter.done()
assert connection.waiter.result() is None
assert connection._context_data == {}
@pytest.mark.asyncio
async def test_recv_loop_still_accepts_camel_case_context_id() -> None:
context_id = "ctx_123"
audio_chunk = b"hello-audio"
connection = _FakeConnection(
context_id,
[
_websocket_text_message(
{
"contextId": context_id,
"audio": base64.b64encode(audio_chunk).decode("ascii"),
"isFinal": True,
}
),
],
)
await elevenlabs_tts._Connection._recv_loop(connection)
assert connection.emitter.audio_chunks == [audio_chunk]
assert connection.waiter.done()
assert connection.waiter.result() is None
assert connection._context_data == {}
@pytest.mark.asyncio
async def test_recv_loop_ignores_flush_done_for_active_context() -> None:
context_id = "ctx_123"
audio_chunk = b"hello-audio"
connection = _FakeConnection(
context_id,
[
_websocket_text_message(
{
"type": "flush_done",
"context_id": context_id,
"status_code": 206,
"done": False,
"data": "",
"flush_done": True,
}
),
_websocket_text_message(
{
"context_id": context_id,
"audio": base64.b64encode(audio_chunk).decode("ascii"),
"isFinal": True,
}
),
],
)
await elevenlabs_tts._Connection._recv_loop(connection)
assert connection.emitter.audio_chunks == [audio_chunk]
assert connection.waiter.done()
assert connection.waiter.result() is None
@pytest.mark.asyncio
async def test_recv_loop_ignores_flush_done_for_inactive_context() -> None:
context_id = "ctx_123"
audio_chunk = b"hello-audio"
connection = _FakeConnection(
context_id,
[
_websocket_text_message(
{
"type": "flush_done",
"context_id": "already_closed_context",
"status_code": 206,
"done": False,
"data": "",
"flush_done": True,
}
),
_websocket_text_message(
{
"context_id": context_id,
"audio": base64.b64encode(audio_chunk).decode("ascii"),
"isFinal": True,
}
),
],
)
await elevenlabs_tts._Connection._recv_loop(connection)
assert connection.emitter.audio_chunks == [audio_chunk]
assert connection.waiter.done()
assert connection.waiter.result() is None