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

682 lines
25 KiB
Python

# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import asyncio
import base64
import json
import os
import time
import weakref
from dataclasses import dataclass, replace
from typing import Any
import aiohttp
from livekit.agents import (
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
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
WEBSOCKET_URL = "wss://tts-rt.soniox.com/tts-websocket"
NUM_CHANNELS = 1
DEFAULT_MODEL = "tts-rt-v1-preview"
DEFAULT_LANGUAGE = "en"
DEFAULT_VOICE = "Maya"
DEFAULT_AUDIO_FORMAT = "pcm_s16le"
DEFAULT_SAMPLE_RATE = 24000
DEFAULT_SPEED = 1.0
# Speaking rate bounds accepted by the Soniox TTS API (values below 1.0 slow
# speech down, above 1.0 speed it up).
MIN_SPEED = 0.7
MAX_SPEED = 1.3
KEEPALIVE_INTERVAL = 10 # seconds
KEEPALIVE_MESSAGE = json.dumps({"keep_alive": True})
def _audio_format_to_mime_type(audio_format: str) -> str:
if audio_format.startswith("pcm"):
return "audio/pcm"
if audio_format == "mp3":
return "audio/mpeg"
return f"audio/{audio_format}"
class TTS(tts.TTS):
"""Text-to-Speech service using Soniox Text-to-Speech API.
This service connects to Soniox Text-to-Speech API for real-time speech synthesis
with support for multiple languages, voices, and audio formats.
For complete API documentation, see: https://soniox.com/docs/api-reference/tts/websocket-api
"""
def __init__(
self,
*,
model: str = DEFAULT_MODEL,
language: str = DEFAULT_LANGUAGE,
voice: str = DEFAULT_VOICE,
audio_format: str = DEFAULT_AUDIO_FORMAT,
sample_rate: int = DEFAULT_SAMPLE_RATE,
bitrate: int | None = None,
speed: float = DEFAULT_SPEED,
api_key: str | None = None,
websocket_url: str = WEBSOCKET_URL,
http_session: aiohttp.ClientSession | None = None,
) -> None:
"""Initialize instance of Soniox Text-to-Speech API service.
Args:
model (str): Soniox TTS model to use. Defaults to "tts-rt-v1-preview".
language (str): Language code (e.g., "en", "es", "fr"). Defaults to "en".
voice (str): Voice name (e.g., "Maya", "Adrian"). Defaults to "Maya".
audio_format (str): Audio format (e.g., "pcm_s16le", "mp3"). Defaults to "pcm_s16le".
sample_rate (int): Sample rate in Hz. Required for raw audio formats. Defaults to 24000.
bitrate (int): Codec bitrate in bps for compressed formats. Optional.
speed (float): Speaking rate. 1.0 is the normal rate; values below 1.0 slow speech
down and values above 1.0 speed it up. Range is [0.7, 1.3]. Defaults to 1.0.
api_key (str): Soniox API key. If not provided, will look for SONIOX_API_KEY env variable.
websocket_url (str): Base WebSocket URL for Soniox TTS API.
http_session (aiohttp.ClientSession): Optional aiohttp.ClientSession to use for requests.
"""
super().__init__(
capabilities=tts.TTSCapabilities(streaming=True),
sample_rate=sample_rate,
num_channels=NUM_CHANNELS,
)
api_key = api_key or os.environ.get("SONIOX_API_KEY")
if not api_key:
raise ValueError("Soniox API key is required. Set SONIOX_API_KEY or provide api_key.")
if not MIN_SPEED <= speed <= MAX_SPEED:
raise ValueError(f"speed must be between {MIN_SPEED} and {MAX_SPEED}, but got {speed}")
self._opts = _TTSOptions(
model=model,
language=language,
voice=voice,
audio_format=audio_format,
sample_rate=sample_rate,
bitrate=bitrate,
speed=speed,
websocket_url=websocket_url,
api_key=api_key,
)
self._session = http_session
self._streams = weakref.WeakSet[SynthesizeStream]()
# One persistent connection shared across streams (see _Connection).
self.__current_connection: _Connection | None = None
self.__conn_lock = asyncio.Lock()
@property
def model(self) -> str:
return self._opts.model
@property
def provider(self) -> str:
return "Soniox"
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
async def _current_connection(self, *, timeout: float) -> tuple[_Connection, float, bool]:
"""Return the live connection, creating one if needed.
Returns ``(connection, acquire_time, reused)`` — matches the
``ConnectionPool`` surface used by other plugins' metrics.
"""
async with self.__conn_lock:
conn = self.__current_connection
if conn is not None and conn.is_current and not conn.closed:
return conn, 0.0, True
# Discard any stale connection reference so it can drain and close
# itself once any outstanding streams finish.
if conn is not None and not conn.closed:
conn.mark_non_current()
t0 = time.perf_counter()
new_conn = _Connection(self._opts, self._ensure_session())
await asyncio.wait_for(new_conn.connect(), timeout=timeout)
self.__current_connection = new_conn
return new_conn, time.perf_counter() - t0, False
def update_options(
self,
*,
model: NotGivenOr[str] = NOT_GIVEN,
language: NotGivenOr[str] = NOT_GIVEN,
voice: NotGivenOr[str] = NOT_GIVEN,
speed: NotGivenOr[float] = NOT_GIVEN,
) -> None:
"""
Args:
model: TTS model to use.
language: Language code to use.
voice: Voice to use.
speed: Speaking rate in the range [0.7, 1.3]; 1.0 is the normal rate.
"""
if is_given(model):
self._opts.model = model
if is_given(language):
self._opts.language = language
if is_given(voice):
self._opts.voice = voice
if is_given(speed):
if not MIN_SPEED <= speed <= MAX_SPEED:
raise ValueError(
f"speed must be between {MIN_SPEED} and {MAX_SPEED}, but got {speed}"
)
self._opts.speed = speed
def synthesize(
self, text: str, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> tts.ChunkedStream:
return self._synthesize_with_stream(text, conn_options=conn_options)
def stream(
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> SynthesizeStream:
"""Create a streaming TTS session."""
stream = SynthesizeStream(tts=self, conn_options=conn_options)
self._streams.add(stream)
return stream
def prewarm(self) -> None:
"""Pre-warm the persistent connection in the background."""
async def _task() -> None:
try:
await self._current_connection(timeout=20.0)
except Exception as e:
logger.debug(f"Soniox TTS prewarm failed: {e}")
try:
asyncio.create_task(_task(), name="soniox-tts-prewarm")
except RuntimeError:
# No running event loop (e.g. called outside async context) — skip.
pass
async def aclose(self) -> None:
"""Close all streams and the persistent connection."""
for stream in list(self._streams):
await stream.aclose()
self._streams.clear()
if self.__current_connection is not None:
await self.__current_connection.aclose()
self.__current_connection = None
class SynthesizeStream(tts.SynthesizeStream):
"""Streaming TTS implementation on a shared _Connection."""
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)
self._stream_id: str = ""
self._connection: _Connection | None = None
self._cancelled = asyncio.Event()
async def aclose(self) -> None:
"""Close the stream, signalling cancel to the server if still active.
Cancelling only affects this stream's ``stream_id``; the underlying
WebSocket stays alive for subsequent streams.
"""
if self._cancelled.is_set():
await super().aclose()
return
self._cancelled.set()
if self._connection is not None and not self._connection.closed and self._stream_id:
self._connection.cancel_stream(self._stream_id)
logger.debug(
"Sent cancellation for Soniox TTS stream",
extra={"stream_id": self._stream_id},
)
await super().aclose()
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
"""Register with the connection, stream text, await the completion future."""
request_id = utils.shortuuid()
self._stream_id = utils.shortuuid()
output_emitter.initialize(
request_id=request_id,
sample_rate=self._opts.sample_rate,
num_channels=NUM_CHANNELS,
mime_type=_audio_format_to_mime_type(self._opts.audio_format),
stream=True,
)
output_emitter.start_segment(segment_id=utils.shortuuid())
try:
(
connection,
self._acquire_time,
self._connection_reused,
) = await self._tts._current_connection(timeout=self._conn_options.timeout)
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 Exception as e:
raise APIConnectionError() from e
self._connection = connection
waiter: asyncio.Future[None] = asyncio.get_event_loop().create_future()
connection.register_stream(self._stream_id, output_emitter, waiter, opts=self._opts)
async def _input_task() -> None:
async for data in self._input_ch:
if self._cancelled.is_set():
break
if isinstance(data, self._FlushSentinel):
continue
self._mark_started()
connection.send_text(self._stream_id, data, text_end=False)
if not self._cancelled.is_set():
connection.send_text(self._stream_id, "", text_end=True)
input_t = asyncio.create_task(_input_task(), name="soniox-tts-stream-input")
try:
await waiter
except APIStatusError:
raise
except Exception as e:
raise APIConnectionError() from e
finally:
output_emitter.end_segment()
await utils.aio.gracefully_cancel(input_t)
connection.unregister_stream(self._stream_id)
@dataclass
class _TTSOptions:
model: str
language: str
voice: str
audio_format: str
sample_rate: int
bitrate: int | None
speed: float
websocket_url: str
api_key: str
@dataclass
class _StartConfig:
stream_id: str
opts: _TTSOptions
@dataclass
class _SendText:
stream_id: str
text: str
text_end: bool
@dataclass
class _CancelStream:
stream_id: str
_OutboundMsg = _StartConfig | _SendText | _CancelStream
@dataclass
class _StreamData:
emitter: tts.AudioEmitter
waiter: asyncio.Future[None]
opts: _TTSOptions
audio_ended: bool = False
# Client cancel and server abort produce identical `terminated` messages.
# This flag is how recv loop tells them apart.
cancel_sent: bool = False
config_sent: bool = False
class _Connection:
"""Persistent Soniox TTS WebSocket connection with multi stream support.
The connection is independent of any single ``SynthesizeStream``: cancelling
a stream only sends ``{"cancel": true}`` for that ``stream_id`` — the
WebSocket stays open and remains available for subsequent streams.
"""
def __init__(self, opts: _TTSOptions, session: aiohttp.ClientSession) -> None:
self._opts = opts
self._session = session
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._streams: dict[str, _StreamData] = {}
self._input_queue = utils.aio.Chan[_OutboundMsg]()
self._send_task: asyncio.Task[None] | None = None
self._recv_task: asyncio.Task[None] | None = None
self._keepalive_task: asyncio.Task[None] | None = None
self._is_current = True
self._closed = False
@property
def is_current(self) -> bool:
return self._is_current
@property
def closed(self) -> bool:
return self._closed
@property
def num_active_streams(self) -> int:
return len(self._streams)
def has_stream(self, stream_id: str) -> bool:
return stream_id in self._streams
def mark_non_current(self) -> None:
"""Flag this connection to be replaced; self-closes once idle."""
self._is_current = False
if not self._streams and not self._closed:
asyncio.create_task(self.aclose(), name="soniox-tts-conn-drain-close")
async def connect(self) -> None:
"""Open the WebSocket and start the send/recv/keepalive loops."""
if self._ws is not None or self._closed:
return
try:
self._ws = await self._session.ws_connect(self._opts.websocket_url)
except Exception:
self._closed = True
self._is_current = False
raise
logger.debug("Soniox TTS WebSocket connection established!")
self._send_task = asyncio.create_task(self._send_loop(), name="soniox-tts-send")
self._recv_task = asyncio.create_task(self._recv_loop(), name="soniox-tts-recv")
self._keepalive_task = asyncio.create_task(
self._keepalive_loop(), name="soniox-tts-keepalive"
)
def register_stream(
self,
stream_id: str,
emitter: tts.AudioEmitter,
waiter: asyncio.Future[None],
*,
opts: _TTSOptions,
) -> None:
"""Register a new stream and queue its config message."""
if self._closed:
if not waiter.done():
waiter.set_exception(APIConnectionError("Soniox TTS connection is closed"))
return
if stream_id in self._streams:
raise ValueError(f"stream_id {stream_id} already registered")
# Server starts a per-stream timeout on _StartConfig receipt; we queue it lazily in send_text.
self._streams[stream_id] = _StreamData(emitter=emitter, waiter=waiter, opts=opts)
def unregister_stream(self, stream_id: str) -> None:
self._streams.pop(stream_id, None)
# If flagged non-current and idle, self-close.
if not self._is_current and not self._streams and not self._closed:
asyncio.create_task(self.aclose(), name="soniox-tts-conn-drain-close")
def send_text(self, stream_id: str, text: str, *, text_end: bool = False) -> None:
if self._closed or stream_id not in self._streams:
return
stream = self._streams[stream_id]
# Empty text would make the server hallucinate audio; handle the two cases locally.
if not text:
if not text_end:
return
if not stream.config_sent:
# Server doesn't know about this stream; resolve locally.
if not stream.waiter.done():
stream.waiter.set_result(None)
self._streams.pop(stream_id, None)
return
if not stream.config_sent:
stream.config_sent = True
self._input_queue.send_nowait(_StartConfig(stream_id=stream_id, opts=stream.opts))
self._input_queue.send_nowait(_SendText(stream_id=stream_id, text=text, text_end=text_end))
def cancel_stream(self, stream_id: str) -> None:
if self._closed:
return
stream = self._streams.get(stream_id)
if stream is None:
return
if not stream.config_sent:
# Server doesn't know about this stream; resolve locally.
if not stream.waiter.done():
stream.waiter.set_result(None)
self._streams.pop(stream_id, None)
return
self._streams[stream_id].cancel_sent = True
self._input_queue.send_nowait(_CancelStream(stream_id=stream_id))
async def _send_loop(self) -> None:
try:
async for msg in self._input_queue:
if self._ws is None or self._ws.closed:
break
if isinstance(msg, _StartConfig):
config: dict[str, Any] = {
"api_key": msg.opts.api_key,
"model": msg.opts.model,
"language": msg.opts.language,
"voice": msg.opts.voice,
"audio_format": msg.opts.audio_format,
"sample_rate": msg.opts.sample_rate,
"speed": msg.opts.speed,
"stream_id": msg.stream_id,
}
if msg.opts.bitrate is not None:
config["bitrate"] = msg.opts.bitrate
await self._ws.send_str(json.dumps(config))
elif isinstance(msg, _SendText):
payload: dict[str, Any] = {"stream_id": msg.stream_id}
if msg.text:
payload["text"] = msg.text
if msg.text_end:
payload["text_end"] = True
await self._ws.send_str(json.dumps(payload))
elif isinstance(msg, _CancelStream):
await self._ws.send_str(
json.dumps({"stream_id": msg.stream_id, "cancel": True})
)
except Exception as e:
logger.warning("Soniox TTS send loop error", exc_info=e)
self._fail_all(APIConnectionError("Soniox TTS send loop error"))
finally:
if not self._closed:
asyncio.create_task(self.aclose(), name="soniox-tts-conn-fail-close")
async def _recv_loop(self) -> None:
try:
while not self._closed and self._ws is not None and not self._ws.closed:
msg = await self._ws.receive()
if msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
):
self._fail_all(
APIStatusError(
"Soniox TTS WebSocket connection closed unexpectedly",
status_code=(self._ws.close_code if self._ws else -1) or -1,
body=f"{msg.data=} {msg.extra=}",
)
)
break
if msg.type != aiohttp.WSMsgType.TEXT:
continue
resp = json.loads(msg.data)
stream_id = resp.get("stream_id")
if stream_id is None:
# Connection-level message (only errors are expected here).
if resp.get("error_code"):
logger.error(
f"Soniox TTS connection-level error: "
f"{resp.get('error_code')} - {resp.get('error_message')}"
)
continue
stream = self._streams.get(stream_id)
if stream is None:
logger.debug(f"Ignoring message for unknown stream {stream_id}")
continue
# Per-stream error.
if resp.get("error_code"):
logger.error(
f"Soniox TTS error: {resp.get('error_code')} - {resp.get('error_message')}",
extra={"stream_id": stream_id},
)
if not stream.waiter.done():
code: int = resp.get("error_code", 500)
# 408/429 are transient (timeout, rate limit); 5xx is
# server-side. Other 4xx (400/401/402) is config/auth
# and retrying won't help.
retryable = code in (408, 429) or code >= 500
stream.waiter.set_exception(
APIStatusError(
message=resp.get("error_message", "Unknown error"),
status_code=code,
body=f"stream_id={stream_id} {msg.data}",
retryable=retryable,
)
)
continue
audio_b64 = resp.get("audio")
if audio_b64:
stream.emitter.push(base64.b64decode(audio_b64))
if resp.get("audio_end"):
stream.audio_ended = True
# end_segment() is called from SynthesizeStream._run's finally
# block (covers cancel/error paths too).
if resp.get("terminated"):
# Don't clobber an exception already raised on the error_code path.
if not stream.waiter.done():
# Server aborted on its own - no audio_end, no cancel from us.
server_error = not stream.audio_ended and not stream.cancel_sent
if server_error:
# Raise so the framework retries, otherwise the
# turn completes with no audio (silent muteness).
logger.warning(
"Soniox TTS stream terminated without audio_end",
extra={"stream_id": stream_id},
)
stream.waiter.set_exception(
APIStatusError(
message=(
"Soniox TTS stream terminated without producing audio"
),
body=f"stream_id={stream_id}",
retryable=True,
)
)
else:
# Normal end: audio_end seen, or our own cancel landed.
stream.waiter.set_result(None)
self._streams.pop(stream_id, None)
except Exception as e:
logger.warning("Soniox TTS recv loop error", exc_info=e)
self._fail_all(APIConnectionError("Soniox TTS recv loop error"))
finally:
if not self._closed:
asyncio.create_task(self.aclose(), name="soniox-tts-conn-fail-close")
async def _keepalive_loop(self) -> None:
try:
while not self._closed and self._ws is not None and not self._ws.closed:
await asyncio.sleep(KEEPALIVE_INTERVAL)
if self._ws is not None and not self._ws.closed:
await self._ws.send_str(KEEPALIVE_MESSAGE)
except Exception as e:
logger.warning(f"Soniox TTS keepalive error: {e}")
def _fail_all(self, err: BaseException) -> None:
"""Fail all registered streams and mark the connection non-current."""
for stream in list(self._streams.values()):
if not stream.waiter.done():
stream.waiter.set_exception(err)
self._streams.clear()
self._is_current = False
async def aclose(self) -> None:
"""Close the connection, failing any remaining streams."""
if self._closed:
return
self._closed = True
self._is_current = False
for stream in list(self._streams.values()):
if not stream.waiter.done():
stream.waiter.set_exception(APIConnectionError("Soniox TTS connection closed"))
self._streams.clear()
self._input_queue.close()
if self._ws is not None and not self._ws.closed:
try:
await self._ws.close()
except Exception as e:
logger.debug(f"Error closing Soniox TTS WebSocket: {e}")
# Cancel all loops except the current task (avoid self-deadlock).
current = asyncio.current_task()
to_cancel = [
t
for t in (self._send_task, self._recv_task, self._keepalive_task)
if t is not None and t is not current
]
if to_cancel:
await utils.aio.gracefully_cancel(*to_cancel)