Files
2026-07-13 13:39:38 +08:00

532 lines
18 KiB
Python

"""
Uplift TTS Plugin for LiveKit, this will soon be available as a python lib
"""
from __future__ import annotations
import asyncio
import base64
import os
import time
import uuid
import weakref
from dataclasses import dataclass
from typing import Any, Literal
import socketio # type: ignore[import-not-found]
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIError,
APITimeoutError,
tokenize,
tts,
utils,
)
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, NotGivenOr
from livekit.agents.utils import is_given
from .log import logger
# Output format options
OutputFormat = Literal[
"PCM_22050_16",
"WAV_22050_16",
"WAV_22050_32",
"MP3_22050_32",
"MP3_22050_64",
"MP3_22050_128",
"OGG_22050_16",
"ULAW_8000_8",
]
# Default configuration
DEFAULT_BASE_URL = "wss://api.upliftai.org"
DEFAULT_SAMPLE_RATE = 22050
DEFAULT_NUM_CHANNELS = 1
DEFAULT_VOICE_ID = "v_meklc281"
DEFAULT_OUTPUT_FORMAT: OutputFormat = "MP3_22050_32"
WEBSOCKET_NAMESPACE = "/text-to-speech/multi-stream"
def get_content_type_from_output_format(output_format: OutputFormat) -> str:
"""Get MIME type based on output format"""
if output_format == "PCM_22050_16":
return "audio/pcm"
elif output_format == "WAV_22050_16":
return "audio/wav"
elif output_format == "WAV_22050_32":
return "audio/wav"
elif output_format.startswith("MP3"):
return "audio/mpeg"
elif output_format.startswith("OGG"):
return "audio/ogg"
elif output_format == "ULAW_8000_8":
return "audio/x-mulaw"
else:
raise ValueError(f"Unsupported output format: {output_format}")
@dataclass
class VoiceSettings:
"""Voice configuration settings"""
voice_id: str = DEFAULT_VOICE_ID
output_format: OutputFormat = DEFAULT_OUTPUT_FORMAT
@dataclass
class _TTSOptions:
"""Internal TTS options"""
base_url: str
api_key: str
voice_settings: VoiceSettings
word_tokenizer: tokenize.WordTokenizer | tokenize.SentenceTokenizer
sample_rate: int
num_channels: int
phrase_replacement_config_id: str | None
class TTS(tts.TTS):
"""Uplift TTS implementation for LiveKit"""
def __init__(
self,
*,
base_url: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
voice_id: str = DEFAULT_VOICE_ID,
output_format: OutputFormat = DEFAULT_OUTPUT_FORMAT,
num_channels: int = DEFAULT_NUM_CHANNELS,
phrase_replacement_config_id: NotGivenOr[str] = NOT_GIVEN,
word_tokenizer: NotGivenOr[tokenize.WordTokenizer | tokenize.SentenceTokenizer] = NOT_GIVEN,
) -> None:
"""
Create a new instance of Uplift TTS.
Args:
base_url: Base URL for TTS service. Defaults to wss://api.upliftai.org
api_key: API key for authentication
voice_id: Voice ID to use. Defaults to "17"
output_format: Audio output format. Options:
- 'PCM_22050_16': PCM format, 22.05kHz, 16-bit
- 'WAV_22050_16': WAV format, 22.05kHz, 16-bit
- 'WAV_22050_32': WAV format, 22.05kHz, 32-bit
- 'MP3_22050_32': MP3 format, 22.05kHz, 32kbps (default)
- 'MP3_22050_64': MP3 format, 22.05kHz, 64kbps
- 'MP3_22050_128': MP3 format, 22.05kHz, 128kbps
- 'OGG_22050_16': OGG format, 22.05kHz, 16-bit
- 'ULAW_8000_8': μ-law format, 8kHz, 8-bit
sample_rate: Sample rate for audio output. Defaults to 22050
num_channels: Number of audio channels. Defaults to 1 (mono)
phrase_replacement_config_id: Optional ID for phrase replacement configuration
word_tokenizer: Tokenizer for processing text. Defaults to `livekit.agents.tokenize.basic.WordTokenizer`.
"""
super().__init__(
capabilities=tts.TTSCapabilities(
streaming=True,
aligned_transcript=False,
),
sample_rate=DEFAULT_SAMPLE_RATE,
num_channels=num_channels,
)
# Get configuration from environment if not provided
resolved_base_url: str = (
base_url
if is_given(base_url)
else os.environ.get("UPLIFTAI_BASE_URL", DEFAULT_BASE_URL)
)
resolved_api_key: str | None = (
api_key if is_given(api_key) else os.environ.get("UPLIFTAI_API_KEY")
)
if not resolved_api_key:
raise ValueError(
"API key is required, either as argument or set UPLIFTAI_API_KEY environment variable"
)
# Use provided tokenizer or create default
resolved_word_tokenizer: tokenize.WordTokenizer | tokenize.SentenceTokenizer
if is_given(word_tokenizer):
resolved_word_tokenizer = word_tokenizer
else:
resolved_word_tokenizer = tokenize.basic.WordTokenizer(ignore_punctuation=False)
self._opts = _TTSOptions(
base_url=resolved_base_url,
api_key=resolved_api_key,
voice_settings=VoiceSettings(voice_id=voice_id, output_format=output_format),
word_tokenizer=resolved_word_tokenizer,
sample_rate=DEFAULT_SAMPLE_RATE,
num_channels=num_channels,
phrase_replacement_config_id=phrase_replacement_config_id
if is_given(phrase_replacement_config_id)
else None,
)
self._client: WebSocketClient | None = None
self._streams = weakref.WeakSet[SynthesizeStream]()
def update_options(
self,
*,
voice_id: NotGivenOr[str] = NOT_GIVEN,
output_format: NotGivenOr[OutputFormat] = NOT_GIVEN,
) -> None:
"""
Update TTS configuration options.
Args:
voice_id: New voice ID
output_format: New output format (see __init__ for options)
"""
if is_given(voice_id):
self._opts.voice_settings.voice_id = voice_id
if is_given(output_format):
self._opts.voice_settings.output_format = output_format
def synthesize(
self, text: str, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> ChunkedStream:
"""Synthesize text to speech using chunked stream."""
return ChunkedStream(tts=self, input_text=text, conn_options=conn_options)
def stream(
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> SynthesizeStream:
"""Create a streaming synthesis session."""
stream = SynthesizeStream(tts=self, conn_options=conn_options)
self._streams.add(stream)
return stream
async def aclose(self) -> None:
"""Clean up resources"""
for stream in list(self._streams):
await stream.aclose()
self._streams.clear()
if self._client:
await self._client.disconnect()
self._client = None
class WebSocketClient:
"""Manages WebSocket connection to TTS service"""
def __init__(self, opts: _TTSOptions):
self.opts = opts
self.sio: socketio.AsyncClient | None = None
self.connected = False
self.audio_callbacks: dict[str, asyncio.Queue[bytes | None]] = {}
self.active_requests: dict[str, bool] = {}
async def connect(self) -> bool:
"""Establish WebSocket connection"""
if self.connected:
return True
try:
self.sio = socketio.AsyncClient(
reconnection=True,
reconnection_attempts=3,
reconnection_delay=1,
logger=False,
engineio_logger=False,
)
# Register handlers
self.sio.on("message", self._on_message, namespace=WEBSOCKET_NAMESPACE)
self.sio.on("connect", self._on_connect, namespace=WEBSOCKET_NAMESPACE)
self.sio.on("disconnect", self._on_disconnect, namespace=WEBSOCKET_NAMESPACE)
# Prepare auth
auth_data = {"token": self.opts.api_key}
# Connect
await self.sio.connect(
self.opts.base_url,
auth=auth_data,
namespaces=[WEBSOCKET_NAMESPACE],
transports=["websocket"],
wait_timeout=10,
)
# Wait for connection
max_wait = 5.0
start_time = time.time()
while not self.connected and (time.time() - start_time) < max_wait:
await asyncio.sleep(0.1)
if not self.connected and self.sio.connected:
self.connected = True
return self.connected
except Exception as e:
logger.error(f"Connection failed: {e}")
return False
async def synthesize(
self, text: str, request_id: str | None = None
) -> asyncio.Queue[bytes | None]:
"""Send synthesis request and return audio queue"""
if not self.sio or not self.connected:
if not await self.connect():
raise ConnectionError("Failed to connect to TTS service")
if not request_id:
request_id = str(uuid.uuid4())
# Create audio queue
audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue()
self.audio_callbacks[request_id] = audio_queue
self.active_requests[request_id] = True
# Build message
message: dict[str, Any] = {
"type": "synthesize",
"requestId": request_id,
"text": text,
"voiceId": self.opts.voice_settings.voice_id,
"outputFormat": self.opts.voice_settings.output_format,
}
if self.opts.phrase_replacement_config_id:
message["phraseReplacementConfigId"] = self.opts.phrase_replacement_config_id
logger.debug(f"Sending synthesis request {request_id[:8]} for text: '{text[:50]}...'")
try:
if self.sio is not None:
await self.sio.emit("synthesize", message, namespace=WEBSOCKET_NAMESPACE)
except Exception as e:
logger.error(f"Failed to emit synthesis: {e}")
del self.audio_callbacks[request_id]
del self.active_requests[request_id]
raise
return audio_queue
async def disconnect(self) -> None:
"""Disconnect from service"""
if self.sio and self.connected:
await self.sio.disconnect()
self.connected = False
async def _on_connect(self) -> None:
"""Handle connection"""
logger.debug("WebSocket connected")
async def _on_message(self, data: Any) -> None:
"""Handle messages"""
message_type = data.get("type")
if message_type == "ready":
self.connected = True
logger.debug(f"Ready with session: {data.get('sessionId')}")
elif message_type == "audio":
request_id = data.get("requestId")
audio_b64 = data.get("audio")
if audio_b64 and request_id in self.audio_callbacks:
audio_bytes = base64.b64decode(audio_b64)
if self.active_requests.get(request_id, False):
await self.audio_callbacks[request_id].put(audio_bytes)
elif message_type == "audio_end":
request_id = data.get("requestId")
if request_id in self.audio_callbacks:
await self.audio_callbacks[request_id].put(None)
del self.audio_callbacks[request_id]
if request_id in self.active_requests:
del self.active_requests[request_id]
elif message_type == "error":
request_id = data.get("requestId", "unknown")
error_msg = data.get("message", str(data))
logger.error(f"Error for {request_id}: {error_msg}")
if request_id in self.audio_callbacks:
await self.audio_callbacks[request_id].put(None)
del self.audio_callbacks[request_id]
if request_id in self.active_requests:
del self.active_requests[request_id]
async def _on_disconnect(self) -> None:
"""Handle disconnection"""
self.connected = False
for queue in self.audio_callbacks.values():
await queue.put(None)
self.audio_callbacks.clear()
self.active_requests.clear()
class ChunkedStream(tts.ChunkedStream):
"""Chunked synthesis implementation"""
def __init__(self, *, tts: TTS, input_text: str, conn_options: APIConnectOptions) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._tts: TTS = tts
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
"""Execute synthesis"""
request_id = utils.shortuuid()
try:
# Initialize emitter
output_emitter.initialize(
request_id=request_id,
sample_rate=self._tts._opts.sample_rate,
num_channels=self._tts._opts.num_channels,
mime_type=get_content_type_from_output_format(
self._tts._opts.voice_settings.output_format
),
)
# Create client if needed
if not self._tts._client:
self._tts._client = WebSocketClient(self._tts._opts)
# Get audio queue
audio_queue = await self._tts._client.synthesize(self._input_text, request_id)
# Stream audio
while True:
try:
audio_data = await asyncio.wait_for(audio_queue.get(), timeout=30.0)
if audio_data is None:
break
output_emitter.push(audio_data)
except asyncio.TimeoutError:
logger.warning("Audio timeout")
break
output_emitter.flush()
except asyncio.TimeoutError as e:
raise APITimeoutError() from e
except Exception as e:
raise APIConnectionError(f"TTS synthesis failed: {str(e)}") from e
class SynthesizeStream(tts.SynthesizeStream):
"""Streaming synthesis implementation"""
def __init__(self, *, tts: TTS, conn_options: APIConnectOptions):
super().__init__(tts=tts, conn_options=conn_options)
self._tts: TTS = tts
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
"""Execute streaming synthesis"""
request_id = utils.shortuuid()
segments_ch = utils.aio.Chan[tokenize.WordStream | tokenize.SentenceStream]()
output_emitter.initialize(
request_id=request_id,
sample_rate=self._tts._opts.sample_rate,
num_channels=self._tts._opts.num_channels,
stream=True,
mime_type=get_content_type_from_output_format(
self._tts._opts.voice_settings.output_format
),
)
async def _tokenize_input() -> None:
"""Tokenize input text"""
word_stream = None
async for input in self._input_ch:
if isinstance(input, str):
if word_stream is None:
word_stream = self._tts._opts.word_tokenizer.stream()
segments_ch.send_nowait(word_stream)
word_stream.push_text(input)
elif isinstance(input, self._FlushSentinel):
if word_stream is not None:
word_stream.end_input()
word_stream = None
if word_stream is not None:
word_stream.end_input()
segments_ch.close()
async def _process_segments() -> None:
"""Process segments"""
async for word_stream in segments_ch:
await self._run_segment(word_stream, output_emitter)
tasks = [
asyncio.create_task(_tokenize_input()),
asyncio.create_task(_process_segments()),
]
try:
await asyncio.gather(*tasks)
except asyncio.TimeoutError:
raise APITimeoutError() from None
except Exception as e:
raise APIConnectionError() from e
finally:
await utils.aio.gracefully_cancel(*tasks)
async def _run_segment(
self,
word_stream: tokenize.WordStream | tokenize.SentenceStream,
output_emitter: tts.AudioEmitter,
) -> None:
"""Process a single segment"""
segment_id = utils.shortuuid()
output_emitter.start_segment(segment_id=segment_id)
try:
# Create client if needed
if not self._tts._client:
self._tts._client = WebSocketClient(self._tts._opts)
# Collect text
text_parts = []
async for data in word_stream:
text_parts.append(data.token)
if not text_parts:
return
# Format text
if isinstance(self._tts._opts.word_tokenizer, tokenize.WordTokenizer):
full_text = self._tts._opts.word_tokenizer.format_words(text_parts)
else:
full_text = " ".join(text_parts)
self._mark_started()
# Synthesize
audio_queue = await self._tts._client.synthesize(full_text, segment_id)
# Stream audio
while True:
try:
audio_data = await asyncio.wait_for(audio_queue.get(), timeout=30.0)
if audio_data is None:
break
output_emitter.push(audio_data)
except asyncio.TimeoutError:
break
output_emitter.end_input()
except Exception as e:
logger.error(f"Segment synthesis error: {e}")
raise APIError(f"Segment synthesis failed: {str(e)}") from e