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

661 lines
24 KiB
Python

from __future__ import annotations
import asyncio
import base64
import contextlib
import json
import os
import weakref
from dataclasses import dataclass, replace
from typing import Literal, get_args
import aiohttp
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
tokenize,
tts,
utils,
)
from livekit.agents.types import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
NotGivenOr,
)
from livekit.agents.utils import is_given
from .gateway_adapter import (
build_tts_init_payload,
is_rime_arcana_model,
normalize_region_override,
normalize_tts_voice,
)
from .log import logger
NUM_CHANNELS = 1
# End-of-stream event names we accept on {"type":"event","data":{...}} messages.
# Sarvam Bulbul emits these when synthesis completes (spec field: data.event_type;
# some internal gateway paths also surface it as data.event, so we accept both).
_EndEventType = Literal["complete", "completed", "done", "end", "final"]
_END_EVENT_TYPES: frozenset[str] = frozenset(get_args(_EndEventType))
def _is_end_event(resp: dict[str, object]) -> bool:
"""True if resp is an end-of-stream notification (e.g. Sarvam Bulbul)."""
if resp.get("type") != "event":
return False
data = resp.get("data")
if not isinstance(data, dict):
return False
raw = data.get("event_type") or data.get("event")
return isinstance(raw, str) and raw.lower() in _END_EVENT_TYPES
# Canonical event kinds emitted by the dispatch helper. Mirrors what the WS
# loops actually need to act on; keeps call sites free of provider-specific
# message shapes.
_EventKind = Literal["audio_chunk", "audio_end", "error", "ignore"]
_AUDIO_CHUNK_TYPES = frozenset({"Audio", "audio", "audio_chunk", "chunk"})
_AUDIO_END_TYPES = frozenset({"Flushed", "audio_end", "end", "flushed"})
_IGNORE_TYPES = frozenset({"Metadata", "Open", "control_ack", "ready"})
_ERROR_TYPES = frozenset({"Error", "error"})
@dataclass
class _ReceivedEvent:
kind: _EventKind
audio: bytes | None = None
error_message: str | None = None
def _extract_audio_b64(resp: dict[str, object]) -> str | None:
"""Pull the base64 audio payload out of one of three provider shapes.
SLNG-fronted providers send audio as either ``resp["data"] = "<b64>"``,
``resp["data"] = {"audio": "<b64>"}`` (Sarvam Bulbul), or
``resp["audio"] = "<b64>"``.
"""
data_field = resp.get("data")
if isinstance(data_field, str):
return data_field
if isinstance(data_field, dict):
nested = data_field.get("audio")
if isinstance(nested, str):
return nested
audio_field = resp.get("audio")
if isinstance(audio_field, str):
return audio_field
return None
def _extract_error_message(resp: dict[str, object]) -> str:
for key in ("message", "description", "error"):
value = resp.get(key)
if isinstance(value, str) and value:
return value
return "Unknown error"
def _parse_received_event(resp: dict[str, object]) -> _ReceivedEvent:
"""Normalize a raw WS message into a typed :class:`_ReceivedEvent`.
Handles the variant message shapes from SLNG-fronted TTS providers
(Sarvam, Deepgram Aura, Rime, etc.) so the WS read loops only deal
with the canonical kinds: ``audio_chunk``, ``audio_end``, ``error``,
``ignore``.
"""
if _is_end_event(resp):
return _ReceivedEvent(kind="audio_end")
mtype = resp.get("type")
if mtype in _IGNORE_TYPES:
return _ReceivedEvent(kind="ignore")
if mtype in _AUDIO_END_TYPES:
return _ReceivedEvent(kind="audio_end")
if mtype in _AUDIO_CHUNK_TYPES:
audio_b64 = _extract_audio_b64(resp)
if not audio_b64:
return _ReceivedEvent(kind="ignore")
try:
return _ReceivedEvent(kind="audio_chunk", audio=base64.b64decode(audio_b64))
except Exception:
logger.warning("[SLNG TTS] invalid base64 audio (audio_chunk)", exc_info=True)
return _ReceivedEvent(kind="ignore")
if mtype in _ERROR_TYPES:
return _ReceivedEvent(kind="error", error_message=_extract_error_message(resp))
logger.debug("[SLNG TTS] ignoring unknown message: %s", resp)
return _ReceivedEvent(kind="ignore")
def _default_tts_endpoint(*, slng_base_url: str, model: str) -> str:
host = slng_base_url.split(":")[0]
protocol = "ws" if host in ("localhost", "127.0.0.1") else "wss"
return f"{protocol}://{slng_base_url}/v1/tts/{model}"
@dataclass
class _TTSOptions:
model_endpoint: str
model: str
voice: str
language: str
sample_rate: int
speed: float
word_tokenizer: tokenize.WordTokenizer
api_key: str
model_options: dict[str, object]
encoding: str = "linear16"
class TTS(tts.TTS):
def __init__(
self,
*,
api_key: str | None = None,
model: str = "deepgram/aura:2",
model_endpoint: str | None = None,
slng_base_url: str = "api.slng.ai",
region_override: str | list[str] | None = None,
voice: str = "default",
language: str = "en",
sample_rate: int = 24000,
speed: float = 1.0,
word_tokenizer: NotGivenOr[tokenize.WordTokenizer] = NOT_GIVEN,
http_session: aiohttp.ClientSession | None = None,
**model_options: object,
) -> None:
"""
Create a new instance of SLNG TTS (based on Deepgram's architecture).
Args:
model (str): SLNG model identifier (e.g., "deepgram/aura:2").
model_endpoint (str): Optional full SLNG WebSocket endpoint.
slng_base_url (str): SLNG gateway host. Defaults to "api.slng.ai".
region_override (str | list[str] | None): Optional gateway region override.
voice (str): Voice to use. Defaults to "default".
language (str): Language code. Defaults to "en".
sample_rate (int): Sample rate of audio. Defaults to 24000.
api_key (str): SLNG API key. Falls back to SLNG_API_KEY environment variable.
word_tokenizer (tokenize.WordTokenizer): Tokenizer for processing text. Defaults to basic WordTokenizer.
http_session (aiohttp.ClientSession): Optional aiohttp session to use for requests.
"""
# Resolve api_key from parameter or SLNG_API_KEY env var
resolved_key = api_key or os.environ.get("SLNG_API_KEY")
if not resolved_key:
raise ValueError("api_key is required, or set SLNG_API_KEY environment variable")
super().__init__(
capabilities=tts.TTSCapabilities(streaming=True),
sample_rate=sample_rate,
num_channels=NUM_CHANNELS,
)
if not is_given(word_tokenizer):
word_tokenizer = tokenize.basic.WordTokenizer(ignore_punctuation=False)
resolved_model_endpoint = model_endpoint or _default_tts_endpoint(
slng_base_url=slng_base_url,
model=model,
)
voice = normalize_tts_voice(model, voice)
# _TTSOptions.encoding defaults to "linear16" because LiveKit expects raw PCM and
# some SLNG models default to MP3 unless explicitly requested.
self._opts = _TTSOptions(
model_endpoint=resolved_model_endpoint,
model=model,
voice=voice,
language=language,
sample_rate=sample_rate,
speed=speed,
word_tokenizer=word_tokenizer,
api_key=resolved_key,
model_options=dict(model_options),
)
self._region_override_header = normalize_region_override(region_override)
self._session = http_session
self._streams = weakref.WeakSet[SynthesizeStream]()
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
connect_cb=self._connect_ws,
close_cb=self._close_ws,
max_session_duration=3600, # 1 hour
mark_refreshed_on_get=False,
)
@property
def model(self) -> str:
return "slng"
@property
def provider(self) -> str:
return "SLNG"
async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
session = self._ensure_session()
# Connect to WebSocket
model_endpoint = self._opts.model_endpoint
headers = {
"Authorization": f"Bearer {self._opts.api_key}",
"X-API-Key": self._opts.api_key,
}
if self._region_override_header:
headers["X-Region-Override"] = self._region_override_header
ws = await asyncio.wait_for(
session.ws_connect(
model_endpoint,
headers=headers,
),
timeout,
)
# SLNG-specific: Send init and wait for ready
init_payload = build_tts_init_payload(
model=self._opts.model,
voice=self._opts.voice,
language=self._opts.language,
sample_rate=self._opts.sample_rate,
encoding=self._opts.encoding,
speed=self._opts.speed,
model_options=self._opts.model_options,
)
try:
await ws.send_str(json.dumps(init_payload))
except Exception:
await ws.close()
raise
return ws
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None:
try:
# Send final flush (similar to Deepgram's Flush+Close pattern).
# Arcana-specific cancel/EOS is handled in the streaming send_task when bypassing
# the connection pool.
await ws.send_str(SynthesizeStream._FLUSH_MSG)
# Wait for server acknowledgment
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(ws.receive(), timeout=5.0)
except Exception as e:
logger.warning(f"[SLNG TTS] error during WebSocket close sequence: {e}")
finally:
await ws.close()
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
def update_options(
self,
*,
voice: NotGivenOr[str] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
speed: NotGivenOr[float] = NOT_GIVEN,
) -> None:
"""
Args:
voice (str): Voice to use.
language (str): Language code.
speed (float): Playback speed multiplier.
"""
invalidate_pool = False
if is_given(voice):
voice = normalize_tts_voice(self._opts.model, voice)
invalidate_pool = invalidate_pool or self._opts.voice != voice
self._opts.voice = voice
if is_given(language):
invalidate_pool = invalidate_pool or self._opts.language != language
self._opts.language = language
if is_given(speed):
invalidate_pool = invalidate_pool or self._opts.speed != speed
self._opts.speed = speed
if invalidate_pool:
self._pool.invalidate()
def synthesize(
self,
text: str,
*,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> ChunkedStream:
return ChunkedStream(tts=self, input_text=text, conn_options=conn_options)
def stream(
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> SynthesizeStream:
stream = SynthesizeStream(tts=self, conn_options=conn_options)
self._streams.add(stream)
return stream
def prewarm(self) -> None:
self._pool.prewarm()
async def aclose(self) -> None:
for stream in list(self._streams):
await stream.aclose()
self._streams.clear()
await self._pool.aclose()
class ChunkedStream(tts.ChunkedStream):
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
self._opts = replace(tts._opts)
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
request_id = utils.shortuuid()
output_emitter.initialize(
request_id=request_id,
sample_rate=self._opts.sample_rate,
num_channels=NUM_CHANNELS,
mime_type="audio/pcm",
)
is_rime_arcana = is_rime_arcana_model(self._opts.model)
ws: aiohttp.ClientWebSocketResponse | None = None
try:
ws = await self._tts._connect_ws(timeout=self._conn_options.timeout)
if self._input_text:
await ws.send_str(json.dumps({"type": "text", "text": self._input_text}))
if is_rime_arcana:
await ws.send_str(SynthesizeStream._CANCEL_MSG)
else:
await ws.send_str(SynthesizeStream._FLUSH_MSG)
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
):
if is_rime_arcana:
output_emitter.flush()
break
raise APIStatusError("SLNG websocket connection closed unexpectedly")
if msg.type == aiohttp.WSMsgType.BINARY:
output_emitter.push(msg.data)
continue
if msg.type != aiohttp.WSMsgType.TEXT:
continue
try:
resp = json.loads(msg.data)
except json.JSONDecodeError:
logger.debug("[SLNG TTS] ignoring non-JSON text frame: %s", msg.data)
continue
if not isinstance(resp, dict):
continue
if "type" not in resp:
is_final_value = resp.get("isFinal")
is_final = (
is_final_value is True
or is_final_value == 1
or (
isinstance(is_final_value, str)
and is_final_value.strip().lower() in ("true", "1")
)
)
audio_b64 = resp.get("audio")
if isinstance(audio_b64, str) and audio_b64:
try:
output_emitter.push(base64.b64decode(audio_b64))
except Exception:
logger.warning(
"[SLNG TTS] invalid base64 audio in chunked synthesis",
exc_info=True,
)
if is_final:
output_emitter.flush()
break
if resp.get("error") is not None:
raise APIStatusError(f"SLNG TTS error: {resp.get('error')}")
continue
event = _parse_received_event(resp)
if event.kind == "ignore":
continue
if event.kind == "audio_chunk":
if event.audio is not None:
output_emitter.push(event.audio)
elif event.kind == "audio_end":
output_emitter.flush()
break
elif event.kind == "error":
raise APIStatusError(f"SLNG TTS error: {event.error_message}")
except asyncio.TimeoutError:
raise APITimeoutError() from None
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=request_id,
body=None,
) from None
except APIStatusError:
raise
except Exception as e:
raise APIConnectionError() from e
finally:
if ws is not None:
await ws.close()
class SynthesizeStream(tts.SynthesizeStream):
# SLNG protocol messages (different from Deepgram)
_FLUSH_MSG: str = json.dumps({"type": "flush"})
_CANCEL_MSG: str = json.dumps({"type": "cancel"})
def __init__(self, *, tts: TTS, conn_options: APIConnectOptions):
super().__init__(tts=tts, conn_options=conn_options)
self._tts: TTS = tts
self._opts = replace(tts._opts)
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
# Create segments_ch per run so base-class retries after an error get a
# fresh channel (matching the Deepgram plugin pattern).
segments_ch = utils.aio.Chan[tokenize.WordStream]()
request_id = utils.shortuuid()
output_emitter.initialize(
request_id=request_id,
sample_rate=self._opts.sample_rate,
num_channels=1,
mime_type="audio/pcm",
stream=True,
)
async def _tokenize_input() -> None:
# Converts incoming text into WordStreams and sends them into segments_ch
word_stream = None
async for the_input in self._input_ch:
if isinstance(the_input, str):
if word_stream is None:
word_stream = self._opts.word_tokenizer.stream()
segments_ch.send_nowait(word_stream)
word_stream.push_text(the_input)
elif isinstance(the_input, self._FlushSentinel):
if word_stream:
word_stream.end_input()
word_stream = None
segments_ch.close()
async def _run_segments() -> None:
async for word_stream in segments_ch:
await self._run_ws(word_stream, output_emitter)
tasks = [
asyncio.create_task(_tokenize_input()),
asyncio.create_task(_run_segments()),
]
try:
await asyncio.gather(*tasks)
except asyncio.TimeoutError:
raise APITimeoutError() from None
except aiohttp.ClientResponseError as e:
raise APIStatusError(
message=e.message,
status_code=e.status,
request_id=request_id,
body=None,
) from None
except APIStatusError:
raise
except Exception as e:
raise APIConnectionError() from e
finally:
await utils.aio.gracefully_cancel(*tasks)
async def _run_ws(
self, word_stream: tokenize.WordStream, output_emitter: tts.AudioEmitter
) -> None:
segment_id = utils.shortuuid()
output_emitter.start_segment(segment_id=segment_id)
input_sent_event = asyncio.Event()
is_rime_arcana = is_rime_arcana_model(self._opts.model)
async def send_task(ws: aiohttp.ClientWebSocketResponse) -> None:
async for word in word_stream:
# SLNG: Use "text" type instead of "Speak"
text_msg = {"type": "text", "text": f"{word.token} "}
self._mark_started()
await ws.send_str(json.dumps(text_msg))
input_sent_event.set()
if is_rime_arcana:
# Rime Arcana supports EOS: synthesize remaining buffer and then close the socket.
# The gateway maps SLNG "cancel" -> "<EOS>" for Arcana.
await ws.send_str(self._CANCEL_MSG)
else:
# Always flush after a segment
# SLNG: Use "flush" type instead of "Flush"
await ws.send_str(self._FLUSH_MSG)
input_sent_event.set()
async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
await input_sent_event.wait()
while True:
msg = await ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
):
# Rime Arcana EOS closes the socket after sending the final audio.
# Treat that close as a normal end-of-segment, otherwise LiveKit will error.
if is_rime_arcana:
output_emitter.end_segment()
break
raise APIStatusError("SLNG websocket connection closed unexpectedly")
# SLNG: Handle both binary (legacy) and JSON audio_chunk messages
if msg.type == aiohttp.WSMsgType.BINARY:
output_emitter.push(msg.data)
elif msg.type == aiohttp.WSMsgType.TEXT:
try:
resp = json.loads(msg.data)
except json.JSONDecodeError:
logger.debug("[SLNG TTS] ignoring non-JSON text frame: %s", msg.data)
continue
if not isinstance(resp, dict):
continue
if "type" not in resp:
is_final_value = resp.get("isFinal")
is_final = (
is_final_value is True
or is_final_value == 1
or (
isinstance(is_final_value, str)
and is_final_value.strip().lower() in ("true", "1")
)
)
audio_b64 = resp.get("audio")
if isinstance(audio_b64, str) and audio_b64:
try:
output_emitter.push(base64.b64decode(audio_b64))
except Exception:
if is_final:
logger.warning(
"[SLNG TTS] invalid base64 audio (isFinal frame)",
exc_info=True,
)
else:
logger.warning(
"[SLNG TTS] invalid base64 audio (audio frame)",
exc_info=True,
)
if is_final:
output_emitter.end_segment()
break
if resp.get("error") is not None:
raise APIStatusError(f"SLNG TTS error: {resp.get('error')}")
continue
event = _parse_received_event(resp)
if event.kind == "ignore":
continue
if event.kind == "audio_chunk":
if event.audio is not None:
output_emitter.push(event.audio)
elif event.kind == "audio_end":
# SLNG: "audio_end" or "end" instead of "Flushed"
output_emitter.end_segment()
break
elif event.kind == "error":
raise APIStatusError(f"SLNG TTS error: {event.error_message}")
if is_rime_arcana:
# EOS closes the websocket; avoid pooling to prevent any cross-segment leakage or reuse
# of a server-closed socket.
ws = await self._tts._connect_ws(timeout=self._conn_options.timeout)
tasks = [
asyncio.create_task(send_task(ws)),
asyncio.create_task(recv_task(ws)),
]
try:
await asyncio.gather(*tasks)
finally:
input_sent_event.set()
await utils.aio.gracefully_cancel(*tasks)
await ws.close()
else:
async with self._tts._pool.connection(timeout=self._conn_options.timeout) as ws:
self._acquire_time = self._tts._pool.last_acquire_time
self._connection_reused = self._tts._pool.last_connection_reused
tasks = [
asyncio.create_task(send_task(ws)),
asyncio.create_task(recv_task(ws)),
]
try:
await asyncio.gather(*tasks)
finally:
input_sent_event.set()
await utils.aio.gracefully_cancel(*tasks)