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

1443 lines
56 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.
"""Text-to-Speech implementation for Sarvam.ai
This module provides a TTS implementation that uses the Sarvam.ai API.
"""
from __future__ import annotations
import asyncio
import base64
import contextlib
import enum
import json
import os
import platform
import weakref
from dataclasses import dataclass, replace
from typing import Literal
import aiohttp
import numpy as np
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
APIConnectionError,
APIConnectOptions,
APIStatusError,
APITimeoutError,
LanguageCode,
__version__ as livekit_version,
tokenize,
tts,
utils,
)
from .log import logger
USER_AGENT = f"Livekit/{livekit_version} Python/{platform.python_version()}"
SARVAM_TTS_BASE_URL = "https://api.sarvam.ai/text-to-speech"
SARVAM_TTS_WS_URL = "wss://api.sarvam.ai/text-to-speech/ws"
# Sarvam TTS WebSocket connections idle out after 60 seconds. We defend against
# this on two layers:
#
# 1. Protocol-level: aiohttp ``heartbeat`` sends WebSocket PING frames every
# ``_WS_HEARTBEAT_INTERVAL`` seconds and auto-handles PONGs even while no
# application-level ``receive()`` is in flight. This is what actually keeps
# the TCP connection alive while it sits idle in the pool.
# 2. Application-level: ``_keepalive_loop`` sends a Sarvam-defined
# ``{"type": "ping"}`` JSON message every ``_KEEPALIVE_INTERVAL`` seconds.
# This guards against a server-side application idle timer that may not be
# reset by protocol-level pings, and serves as a fast detector for stale
# pooled connections.
_WS_HEARTBEAT_INTERVAL: float = 20.0
_KEEPALIVE_INTERVAL: float = 30.0
# Sarvam TTS specific models and speakers
SarvamTTSModels = Literal["bulbul:v2", "bulbul:v3-beta", "bulbul:v3"]
SarvamTTSOutputAudioBitrate = Literal["32k", "64k", "96k", "128k", "192k"]
ALLOWED_OUTPUT_AUDIO_BITRATES: set[str] = {"32k", "64k", "96k", "128k", "192k"}
ALLOWED_OUTPUT_AUDIO_CODECS: set[str] = {
"mp3",
"opus",
"flac",
"aac",
"wav",
"linear16",
"mulaw",
"alaw",
}
_CODEC_TO_MIME: dict[str, str] = {
"mp3": "audio/mp3",
"wav": "audio/wav",
"opus": "audio/opus",
"flac": "audio/flac",
"aac": "audio/aac",
"linear16": "audio/pcm",
# G.711 telephony codecs are decoded to linear 16-bit PCM in this plugin
# before being pushed to the AudioEmitter, so they advertise audio/pcm.
"mulaw": "audio/pcm",
"alaw": "audio/pcm",
}
_TELEPHONY_CODECS: frozenset[str] = frozenset({"mulaw", "alaw"})
def _codec_to_mime_type(codec: str) -> str:
"""Map a Sarvam output_audio_codec value to the MIME type the framework decoder expects."""
mime = _CODEC_TO_MIME.get(codec)
if mime is None:
raise ValueError(f"Unsupported output_audio_codec: {codec}")
return mime
def _build_mulaw_table() -> np.ndarray:
"""Build an ITU-T G.711 mu-law to linear 16-bit PCM lookup table."""
table = np.zeros(256, dtype=np.int16)
for i in range(256):
u = (~i) & 0xFF
sign = -1 if (u & 0x80) else 1
exponent = (u >> 4) & 0x07
mantissa = u & 0x0F
sample = ((mantissa << 3) + 0x84) << exponent
table[i] = sign * (sample - 0x84)
return table
def _build_alaw_table() -> np.ndarray:
"""Build an ITU-T G.711 A-law to linear 16-bit PCM lookup table.
Note: A-law sign convention is inverted relative to mu-law -- when the
sign bit of the XOR'd byte is set, the sample is positive.
"""
table = np.zeros(256, dtype=np.int16)
for i in range(256):
a = i ^ 0x55
sign = 1 if (a & 0x80) else -1
exponent = (a >> 4) & 0x07
mantissa = a & 0x0F
if exponent == 0:
sample = (mantissa << 4) + 8
else:
sample = ((mantissa << 4) + 0x108) << (exponent - 1)
table[i] = sign * sample
return table
_MULAW_TABLE: np.ndarray = _build_mulaw_table()
_ALAW_TABLE: np.ndarray = _build_alaw_table()
def _decode_telephony(codec: str, data: bytes) -> bytes:
"""Decode 8-bit G.711 mu-law/A-law samples to little-endian linear 16-bit PCM."""
if codec == "mulaw":
table = _MULAW_TABLE
elif codec == "alaw":
table = _ALAW_TABLE
else:
raise ValueError(f"_decode_telephony does not support codec: {codec}")
pcm = table[np.frombuffer(data, dtype=np.uint8)]
return pcm.astype("<i2").tobytes()
# Supported languages in BCP-47 format
SarvamTTSLanguages = Literal[
"bn-IN", # Bengali
"en-IN", # English (India)
"gu-IN", # Gujarati
"hi-IN", # Hindi
"kn-IN", # Kannada
"ml-IN", # Malayalam
"mr-IN", # Marathi
"od-IN", # Odia
"pa-IN", # Punjabi
"ta-IN", # Tamil
"te-IN", # Telugu
]
SarvamTTSSpeakers = Literal[
# bulbul:v2 Female (lowercase)
"anushka",
"manisha",
"vidya",
"arya",
# bulbul:v2 Male (lowercase)
"abhilash",
"karun",
"hitesh",
# bulbul:v3-beta Customer Care
"shubh",
"ritu",
"rahul",
"pooja",
"simran",
"kavya",
"amit",
"ratan",
"rohan",
"dev",
"ishita",
"shreya",
"manan",
"sumit",
"priya",
# bulbul:v3-beta Content Creation
"aditya",
"kabir",
"neha",
"varun",
"roopa",
"aayan",
"ashutosh",
"advait",
# bulbul:v3-beta International
"amelia",
"sophia",
# bulbul:v3
"suhani",
"rupali",
"tanya",
"shruti",
"kavitha",
]
# Model-Speaker compatibility mapping
MODEL_SPEAKER_COMPATIBILITY = {
"bulbul:v2": {
"female": ["anushka", "manisha", "vidya", "arya"],
"male": ["abhilash", "karun", "hitesh"],
"all": ["anushka", "manisha", "vidya", "arya", "abhilash", "karun", "hitesh"],
},
"bulbul:v3-beta": {
"female": [
"ritu",
"pooja",
"simran",
"kavya",
"ishita",
"shreya",
"priya",
"neha",
"roopa",
"amelia",
"sophia",
],
"male": [
"shubh",
"rahul",
"amit",
"ratan",
"rohan",
"dev",
"manan",
"sumit",
"aditya",
"kabir",
"varun",
"aayan",
"ashutosh",
"advait",
],
"all": [
"shubh",
"ritu",
"rahul",
"pooja",
"simran",
"kavya",
"amit",
"ratan",
"rohan",
"dev",
"ishita",
"shreya",
"manan",
"sumit",
"priya",
"aditya",
"kabir",
"neha",
"varun",
"roopa",
"aayan",
"ashutosh",
"advait",
"amelia",
"sophia",
],
},
"bulbul:v3": {
"female": [
"ritu",
"pooja",
"simran",
"kavya",
"ishita",
"shreya",
"priya",
"neha",
"roopa",
"amelia",
"sophia",
"suhani",
"rupali",
"tanya",
"shruti",
"kavitha",
],
"male": [
"shubh",
"rahul",
"amit",
"ratan",
"rohan",
"dev",
"manan",
"sumit",
"aditya",
"kabir",
"varun",
"aayan",
"ashutosh",
"advait",
],
"all": [
"shubh",
"ritu",
"rahul",
"pooja",
"simran",
"kavya",
"amit",
"ratan",
"rohan",
"dev",
"ishita",
"shreya",
"manan",
"sumit",
"priya",
"aditya",
"kabir",
"neha",
"varun",
"roopa",
"aayan",
"ashutosh",
"advait",
"amelia",
"sophia",
"suhani",
"rupali",
"tanya",
"shruti",
"kavitha",
],
},
}
class ConnectionState(enum.Enum):
"""WebSocket connection states for TTS."""
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
FAILED = "failed"
def validate_model_speaker_compatibility(model: str, speaker: str) -> bool:
"""Validate that the speaker is compatible with the model version."""
if model not in MODEL_SPEAKER_COMPATIBILITY:
logger.warning(f"Unknown model '{model}', skipping compatibility check")
return True
compatible_speakers = MODEL_SPEAKER_COMPATIBILITY[model]["all"]
if speaker.lower() not in compatible_speakers:
logger.error(
f"Speaker '{speaker}' is not compatible with model '{model}'. "
f"Compatible speakers for {model}: {', '.join(compatible_speakers)}"
)
return False
return True
@dataclass
class SarvamTTSOptions:
"""Options for the Sarvam.ai TTS service.
Args:
target_language_code: BCP-47 language code for supported Indian languages
api_key: Sarvam.ai API key
text: The text to synthesize (will be provided by stream adapter)
speaker: Voice to use for synthesis
pitch: Voice pitch adjustment (-0.75 to 0.75)
pace: Speech rate multiplier (0.3 to 3.0)
loudness: Volume multiplier (0.5 to 2.0)
temperature: Sampling temperature (0.01 to 2.0), used for v3 and v3-beta
output_audio_bitrate: Output audio bitrate
min_buffer_size: Minimum character length for flushing
max_chunk_length: Maximum chunk length for sentence splitting
speech_sample_rate: Audio sample rate (8000, 16000, 22050, 24000, 32000, 44100, or 48000)
enable_preprocessing: Whether to use text preprocessing (bulbul:v2 only)
dict_id: Custom pronunciation dictionary ID (bulbul:v3 only)
enable_cached_responses: Enable response caching beta feature (bulbul:v1/v2 only)
model: The Sarvam TTS model to use
base_url: API endpoint URL
ws_url: WebSocket endpoint URL
word_tokenizer: Tokenizer for processing text
"""
target_language_code: SarvamTTSLanguages | str # BCP-47 for supported Indian languages
api_key: str # Sarvam.ai API key
text: str | None = None # Will be provided by the stream adapter
speaker: SarvamTTSSpeakers | str | None = None
pitch: float = 0.0
pace: float = 1.0
loudness: float = 1.0
temperature: float = 0.6
output_audio_bitrate: SarvamTTSOutputAudioBitrate | str = "128k"
min_buffer_size: int = 50
max_chunk_length: int = 150
speech_sample_rate: int = 22050 # Default 22050 Hz
enable_preprocessing: bool = False
dict_id: str | None = None # Custom pronunciation dictionary (bulbul:v3 only)
enable_cached_responses: bool | None = None # Response caching beta (bulbul:v1/v2 only)
model: SarvamTTSModels | str = "bulbul:v2" # Default to v2
base_url: str = SARVAM_TTS_BASE_URL
ws_url: str = SARVAM_TTS_WS_URL
word_tokenizer: tokenize.tokenizer.SentenceTokenizer | None = None
send_completion_event: bool = True
output_audio_codec: str = "mp3"
class TTS(tts.TTS):
"""Sarvam.ai Text-to-Speech implementation.
This class provides text-to-speech functionality using the Sarvam.ai API.
Sarvam.ai specializes in high-quality TTS for Indian languages.
Args:
target_language_code: BCP-47 language code for supported Indian languages
model: Sarvam TTS model to use (bulbul:v2)
speaker: Voice to use for synthesis
speech_sample_rate: Audio sample rate in Hz
num_channels: Number of audio channels (Sarvam outputs mono)
pitch: Voice pitch adjustment (-0.75 to 0.75) - only supported in v2 for now
pace: Speech rate multiplier (0.3 to 3.0)
loudness: Volume multiplier (0.5 to 2.0) - only supported in v2 for now
temperature: Sampling temperature (0.01 to 2.0), only used in v3 and v3-beta
dict_id: Custom pronunciation dictionary ID (bulbul:v3 only)
enable_cached_responses: Enable response caching beta feature (bulbul:v1/v2 only)
output_audio_bitrate: Output audio bitrate (default 128k)
min_buffer_size: Minimum character length for flushing (30 to 200)
max_chunk_length: Maximum chunk length for sentence splitting (50 to 500)
enable_preprocessing: Whether to use text preprocessing
api_key: Sarvam.ai API key (required)
base_url: API endpoint URL
ws_url: WebSocket endpoint URL
http_session: Optional aiohttp session to use
output_audio_codec: Optionally choose the output codec format (mp3)
"""
def __init__(
self,
*,
target_language_code: SarvamTTSLanguages | str = "en-IN",
model: SarvamTTSModels | str = "bulbul:v3",
speaker: SarvamTTSSpeakers | str | None = None,
speech_sample_rate: int = 22050,
num_channels: int = 1, # Sarvam output is mono WAV
pitch: float = 0.0,
pace: float = 1.0,
loudness: float = 1.0,
temperature: float = 0.6,
output_audio_bitrate: SarvamTTSOutputAudioBitrate | str = "128k",
min_buffer_size: int = 50,
max_chunk_length: int = 150,
enable_preprocessing: bool = False,
dict_id: str | None = None,
enable_cached_responses: bool | None = None,
api_key: str | None = None,
base_url: str = SARVAM_TTS_BASE_URL,
ws_url: str = SARVAM_TTS_WS_URL,
http_session: aiohttp.ClientSession | None = None,
send_completion_event: bool = True,
output_audio_codec: str = "mp3",
) -> None:
super().__init__(
capabilities=tts.TTSCapabilities(streaming=True),
sample_rate=speech_sample_rate,
num_channels=num_channels,
)
self._api_key = api_key or os.environ.get("SARVAM_API_KEY")
if not self._api_key:
raise ValueError(
"Sarvam API key is required. Provide it directly or set SARVAM_API_KEY env var."
)
# Validate inputs early
if not target_language_code or not target_language_code.strip():
raise ValueError("Target language code is required and cannot be empty")
if not model or not model.strip():
raise ValueError("Model is required and cannot be empty")
if speaker is None:
# speaker = "shubh"
if model == "bulbul:v3-beta" or model == "bulbul:v3":
speaker = "shubh"
else:
speaker = "anushka"
# Validate parameter ranges
if not -0.75 <= pitch <= 0.75:
logger.warning(
"pitch value %.2f is outside the Sarvam API accepted range [-0.75, 0.75]; "
"clamping to nearest bound. Please update your code.",
pitch,
)
pitch = max(-0.75, min(0.75, pitch))
if not 0.3 <= pace <= 3.0:
raise ValueError("Pace must be between 0.3 and 3.0")
if not 0.5 <= loudness <= 2.0:
raise ValueError("Loudness must be between 0.5 and 2.0")
if not 0.01 <= temperature <= 2.0:
raise ValueError("Temperature must be between 0.01 and 2.0")
if output_audio_bitrate not in ALLOWED_OUTPUT_AUDIO_BITRATES:
raise ValueError(
f"output_audio_bitrate must be one of {', '.join(sorted(ALLOWED_OUTPUT_AUDIO_BITRATES))}"
)
if not 30 <= min_buffer_size <= 200:
raise ValueError("min_buffer_size must be between 30 and 200")
if not 50 <= max_chunk_length <= 500:
raise ValueError("max_chunk_length must be between 50 and 500")
if speech_sample_rate not in [8000, 16000, 22050, 24000, 32000, 44100, 48000]:
raise ValueError(
"Sample rate must be one of 8000, 16000, 22050, 24000, 32000, 44100, or 48000 Hz"
)
if output_audio_codec not in ALLOWED_OUTPUT_AUDIO_CODECS:
raise ValueError(
f"output_audio_codec must be one of {','.join(sorted(ALLOWED_OUTPUT_AUDIO_CODECS))}"
)
# Validate model-speaker compatibility
if not validate_model_speaker_compatibility(model, speaker):
compatible_speakers = MODEL_SPEAKER_COMPATIBILITY.get(model, {}).get("all", [])
raise ValueError(
f"Speaker '{speaker}' is not compatible with model '{model}'. "
f"Please choose a compatible speaker from: {', '.join(compatible_speakers)}"
)
# Initialize word tokenizer for streaming
word_tokenizer = tokenize.basic.SentenceTokenizer()
self._opts = SarvamTTSOptions(
target_language_code=LanguageCode(target_language_code),
model=model,
speaker=speaker,
speech_sample_rate=speech_sample_rate,
pitch=pitch,
pace=pace,
loudness=loudness,
temperature=temperature,
output_audio_bitrate=output_audio_bitrate,
min_buffer_size=min_buffer_size,
max_chunk_length=max_chunk_length,
enable_preprocessing=enable_preprocessing,
dict_id=dict_id,
enable_cached_responses=enable_cached_responses,
api_key=self._api_key,
base_url=base_url,
ws_url=ws_url,
word_tokenizer=word_tokenizer,
send_completion_event=send_completion_event,
output_audio_codec=output_audio_codec,
)
self._session = http_session
self._streams = weakref.WeakSet[SynthesizeStream]()
# Maps id(ws) -> background keepalive task that pings the server while
# the connection sits idle in the pool. Sarvam closes idle connections
# after 60 s; pinging every 30 s keeps them alive for reuse.
self._ws_keepalive_tasks: dict[int, asyncio.Task[None]] = {}
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,
)
async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
session = self._ensure_session()
headers = {
"api-subscription-key": self._opts.api_key,
"User-Agent": USER_AGENT,
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate, br",
}
# Add model parameter to URL like the client does
ws_url = f"{self._opts.ws_url}?model={self._opts.model}&send_completion_event={self._opts.send_completion_event}"
logger.info("Connecting to Sarvam TTS WebSocket")
try:
ws = await asyncio.wait_for(
session.ws_connect(
ws_url,
headers=headers,
# Send protocol-level WebSocket PING frames every
# ``_WS_HEARTBEAT_INTERVAL`` seconds. aiohttp handles the
# PONG accounting on its own and will close the connection
# locally if the server stops responding -- this is what
# actually keeps the TCP connection alive while it sits
# idle in the pool (no ``receive()`` call to auto-pong).
heartbeat=_WS_HEARTBEAT_INTERVAL,
),
timeout,
)
except Exception as e:
logger.error(
"Failed to connect to Sarvam TTS WebSocket",
extra={"error": str(e), "url": ws_url},
exc_info=True,
)
raise APIConnectionError(f"WebSocket connection failed: {e}") from e
self._start_keepalive(ws)
return ws
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None:
await self._stop_keepalive(ws)
await ws.close()
def _start_keepalive(self, ws: aiohttp.ClientWebSocketResponse) -> None:
"""Spawn a background task that keeps ``ws`` alive with periodic pings.
Idempotent: if a live keepalive task is already registered for ``ws``
the call is a no-op. Callers that need a fresh task must invoke
``_stop_keepalive`` first.
"""
if _KEEPALIVE_INTERVAL <= 0:
return
existing = self._ws_keepalive_tasks.get(id(ws))
if existing is not None and not existing.done():
return
task = asyncio.create_task(self._keepalive_loop(ws), name="sarvam-tts-ws-keepalive")
self._ws_keepalive_tasks[id(ws)] = task
async def _stop_keepalive(self, ws: aiohttp.ClientWebSocketResponse) -> None:
"""Cancel the keepalive task associated with ``ws`` (if any)."""
task = self._ws_keepalive_tasks.pop(id(ws), None)
if task is None or task.done():
return
task.cancel()
with contextlib.suppress(BaseException):
await task
async def _keepalive_loop(self, ws: aiohttp.ClientWebSocketResponse) -> None:
"""Keep a pooled WebSocket alive while it sits idle.
This loop does two things in lockstep:
1. Actively calls ``ws.receive()`` so that aiohttp can process
server-initiated PONG frames (and any other messages). aiohttp
only resets its internal "PONG not received" timer when a PONG
is read via ``receive()`` -- without an active reader, the
protocol-level heartbeat will tear the connection down even
though the server is happily replying.
2. Sends a Sarvam-defined ``{"type": "ping"}`` JSON message
whenever ``receive()`` times out (i.e. nothing has come in for
``_KEEPALIVE_INTERVAL`` seconds). This resets Sarvam's
server-side application idle timer (documented as 60 s).
Any CLOSE/CLOSED/CLOSING/ERROR message from the server, or any
write failure, evicts the connection from the pool so the next
checkout creates a fresh one instead of handing the dead one out.
"""
try:
while not ws.closed:
try:
msg = await asyncio.wait_for(ws.receive(), timeout=_KEEPALIVE_INTERVAL)
except asyncio.TimeoutError:
# No incoming message within the interval -- send our
# app-level ping to reset Sarvam's idle timer.
if ws.closed:
return
try:
await ws.send_str(json.dumps({"type": "ping"}))
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug(
"Sarvam TTS keepalive ping failed; evicting connection from pool",
extra={"error": str(e)},
)
with contextlib.suppress(Exception):
self._pool.remove(ws)
return
continue
# We received something. CLOSE/CLOSED/CLOSING/ERROR means
# the server tore the connection down -- evict and exit.
if msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.ERROR,
):
logger.debug(
"Sarvam TTS WebSocket closed while idle in pool; evicting connection",
extra={
"msg_type": str(msg.type),
"close_code": ws.close_code,
},
)
with contextlib.suppress(Exception):
self._pool.remove(ws)
return
# Otherwise -- PONG, TEXT, BINARY, etc. -- discard. We are
# idle in the pool so any unsolicited TTS traffic from a
# previous request is no longer relevant. The act of
# receiving has already reset aiohttp's heartbeat.
except asyncio.CancelledError:
pass
@property
def model(self) -> str:
return self._opts.model
@property
def provider(self) -> str:
return "Sarvam"
def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = utils.http_context.http_session()
return self._session
def update_options(
self,
*,
model: str | None = None,
target_language_code: SarvamTTSLanguages | str | None = None,
speaker: str | None = None,
pitch: float | None = None,
pace: float | None = None,
loudness: float | None = None,
temperature: float | None = None,
output_audio_bitrate: SarvamTTSOutputAudioBitrate | str | None = None,
min_buffer_size: int | None = None,
max_chunk_length: int | None = None,
enable_preprocessing: bool | None = None,
dict_id: str | None = None,
enable_cached_responses: bool | None = None,
send_completion_event: bool | None = None,
output_audio_codec: str | None = None,
) -> None:
"""Update TTS options with validation."""
if target_language_code is not None:
if not target_language_code.strip():
raise ValueError("Target language code cannot be empty")
self._opts.target_language_code = LanguageCode(target_language_code)
if model is not None:
if not model.strip():
raise ValueError("Model cannot be empty")
self._opts.model = model
if speaker is None and self._opts.speaker is not None:
if not validate_model_speaker_compatibility(self._opts.model, self._opts.speaker):
compatible_speakers = MODEL_SPEAKER_COMPATIBILITY.get(self._opts.model, {}).get(
"all", []
)
raise ValueError(
f"Speaker '{self._opts.speaker}' incompatible with {self._opts.model}. "
f"Compatible speakers: {', '.join(compatible_speakers)}"
)
if speaker is not None:
if not speaker.strip():
raise ValueError("Speaker cannot be empty")
if not validate_model_speaker_compatibility(self._opts.model, speaker):
compatible_speakers = MODEL_SPEAKER_COMPATIBILITY.get(self._opts.model, {}).get(
"all", []
)
raise ValueError(
f"Speaker '{speaker}' incompatible with {self._opts.model}. "
f"Compatible speakers: {', '.join(compatible_speakers)}"
)
self._opts.speaker = speaker
if pitch is not None:
if not -0.75 <= pitch <= 0.75:
logger.warning(
"pitch value %.2f is outside the Sarvam API accepted range [-0.75, 0.75]; "
"clamping to nearest bound. Please update your code.",
pitch,
)
pitch = max(-0.75, min(0.75, pitch))
self._opts.pitch = pitch
if pace is not None:
if not 0.3 <= pace <= 3.0:
raise ValueError("Pace must be between 0.3 and 3.0")
self._opts.pace = pace
if loudness is not None:
if not 0.5 <= loudness <= 2.0:
raise ValueError("Loudness must be between 0.5 and 2.0")
self._opts.loudness = loudness
if temperature is not None:
if not 0.01 <= temperature <= 2.0:
raise ValueError("Temperature must be between 0.01 and 2.0")
self._opts.temperature = temperature
if output_audio_bitrate is not None:
if output_audio_bitrate not in ALLOWED_OUTPUT_AUDIO_BITRATES:
raise ValueError(
"output_audio_bitrate must be one of "
f"{', '.join(sorted(ALLOWED_OUTPUT_AUDIO_BITRATES))}"
)
self._opts.output_audio_bitrate = output_audio_bitrate
if min_buffer_size is not None:
if not 30 <= min_buffer_size <= 200:
raise ValueError("min_buffer_size must be between 30 and 200")
self._opts.min_buffer_size = min_buffer_size
if max_chunk_length is not None:
if not 50 <= max_chunk_length <= 500:
raise ValueError("max_chunk_length must be between 50 and 500")
self._opts.max_chunk_length = max_chunk_length
if enable_preprocessing is not None:
self._opts.enable_preprocessing = enable_preprocessing
if dict_id is not None:
self._opts.dict_id = dict_id
if enable_cached_responses is not None:
self._opts.enable_cached_responses = enable_cached_responses
if send_completion_event is not None:
self._opts.send_completion_event = send_completion_event
if output_audio_codec is not None:
if output_audio_codec not in ALLOWED_OUTPUT_AUDIO_CODECS:
raise ValueError(
"output_audio_codec must be one of "
f"{','.join(sorted(ALLOWED_OUTPUT_AUDIO_CODECS))}"
)
self._opts.output_audio_codec = output_audio_codec
# Implement the abstract synthesize method
def synthesize(
self, text: str, *, conn_options: APIConnectOptions | None = None
) -> ChunkedStream:
"""Synthesize text to audio using Sarvam.ai TTS API."""
if conn_options is None:
conn_options = DEFAULT_API_CONNECT_OPTIONS
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 TTS session."""
stream = SynthesizeStream(tts=self, conn_options=conn_options)
self._streams.add(stream)
return stream
def prewarm(self) -> None:
"""Prewarm WebSocket connections."""
self._pool.prewarm()
async def aclose(self) -> None:
"""Close all active streams and connections."""
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:
"""Run the Sarvam.ai TTS request and emit audio via the output emitter."""
payload = {
"target_language_code": self._opts.target_language_code,
"text": self._input_text,
"speaker": self._opts.speaker,
"pace": self._opts.pace,
"speech_sample_rate": self._opts.speech_sample_rate,
"model": self._opts.model,
"output_audio_bitrate": self._opts.output_audio_bitrate,
"min_buffer_size": self._opts.min_buffer_size,
"max_chunk_length": self._opts.max_chunk_length,
"output_audio_codec": self._opts.output_audio_codec,
}
# Only include pitch and loudness for v2 model (not supported in v3 or v3-beta)
if self._opts.model == "bulbul:v2":
payload["pitch"] = self._opts.pitch
payload["loudness"] = self._opts.loudness
payload["enable_preprocessing"] = self._opts.enable_preprocessing
if self._opts.enable_cached_responses is not None:
payload["enable_cached_responses"] = self._opts.enable_cached_responses
# temperature is supported only for v3 and v3-beta; ignored for v2
if self._opts.model in ("bulbul:v3", "bulbul:v3-beta"):
payload["temperature"] = self._opts.temperature
# dict_id is supported only for v3 (not v3-beta)
if self._opts.model == "bulbul:v3" and self._opts.dict_id is not None:
payload["dict_id"] = self._opts.dict_id
headers = {
"api-subscription-key": self._opts.api_key,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
}
mime_type = _codec_to_mime_type(self._opts.output_audio_codec)
try:
async with self._tts._ensure_session().post(
url=self._opts.base_url,
json=payload,
headers=headers,
timeout=aiohttp.ClientTimeout(
total=self._conn_options.timeout,
sock_connect=self._conn_options.timeout,
),
) as res:
if res.status != 200:
error_text = await res.text()
logger.error(f"Sarvam TTS API error: {res.status} - {error_text}")
raise APIStatusError(
message=f"Sarvam TTS API Error ({res.status}): {error_text}",
status_code=res.status,
body=error_text,
)
response_json = await res.json()
request_id = response_json.get("request_id", "")
audios = response_json.get("audios", [])
if not audios or not isinstance(audios, list):
raise APIConnectionError("Sarvam TTS API response invalid: no audio data")
output_emitter.initialize(
request_id=request_id or "unknown",
sample_rate=self._tts.sample_rate,
num_channels=self._tts.num_channels,
mime_type=mime_type,
)
# handle multiple audio chunks
for b64 in audios:
wav_bytes = base64.b64decode(b64)
if self._opts.output_audio_codec in _TELEPHONY_CODECS:
wav_bytes = _decode_telephony(self._opts.output_audio_codec, wav_bytes)
output_emitter.push(wav_bytes)
except asyncio.TimeoutError as e:
raise APITimeoutError("Sarvam TTS API request timed out") from e
except aiohttp.ClientError as e:
raise APIConnectionError(f"Sarvam TTS API connection error: {e}") from e
class SynthesizeStream(tts.SynthesizeStream):
"""WebSocket-based streaming TTS for Sarvam.ai."""
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._segments_ch = utils.aio.Chan[tokenize.SentenceStream]()
# Connection state management
self._connection_state = ConnectionState.DISCONNECTED
self._session_id = id(self)
self._client_request_id: str | None = None
self._server_request_id: str | None = None
# Task management for cleanup
self._send_task: asyncio.Task | None = None
self._recv_task: asyncio.Task | None = None
self._ws_conn: aiohttp.ClientWebSocketResponse | None = None
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
self._segments_ch = utils.aio.Chan[tokenize.SentenceStream]()
request_id = utils.shortuuid()
self._client_request_id = request_id
self._server_request_id = None
mime_type = _codec_to_mime_type(self._opts.output_audio_codec)
output_emitter.initialize(
request_id=request_id,
sample_rate=self._opts.speech_sample_rate,
num_channels=1,
mime_type=mime_type,
stream=True,
frame_size_ms=50,
)
async def _tokenize_input() -> None:
"""tokenize text from the input_ch to sentences"""
word_stream = None
async for input in self._input_ch:
if isinstance(input, str):
if word_stream is None:
tokenizer_instance: tokenize.tokenizer.SentenceTokenizer
if self._opts.word_tokenizer is None:
# Fallback to basic tokenizer if none provided
tokenizer_instance = tokenize.basic.SentenceTokenizer()
else:
tokenizer_instance = self._opts.word_tokenizer
word_stream = tokenizer_instance.stream()
self._segments_ch.send_nowait(word_stream)
word_stream.push_text(input)
elif isinstance(input, self._FlushSentinel):
if word_stream:
word_stream.end_input()
word_stream = None
if word_stream is not None:
word_stream.end_input()
self._segments_ch.close()
async def _process_segments() -> None:
async for word_stream in self._segments_ch:
await self._run_ws(word_stream, output_emitter)
tasks = [
asyncio.create_task(_tokenize_input()),
asyncio.create_task(_process_segments()),
]
try:
await asyncio.gather(*tasks)
except (APIStatusError, APIConnectionError, APITimeoutError):
raise
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(f"TTS stream failed: {e}") from e
finally:
await utils.aio.gracefully_cancel(*tasks)
output_emitter.end_input()
async def _run_ws(
self, word_stream: tokenize.SentenceStream, output_emitter: tts.AudioEmitter
) -> None:
segment_id = utils.shortuuid()
output_emitter.start_segment(segment_id=segment_id)
logger.info(
"Starting TTS WebSocket session",
extra={**self._build_log_context(), "user-agent": USER_AGENT},
)
input_sent_event = asyncio.Event()
async def send_task(ws: aiohttp.ClientWebSocketResponse) -> None:
try:
data: dict[str, object] = {
"target_language_code": self._opts.target_language_code,
"speaker": self._opts.speaker,
"pace": self._opts.pace,
"model": self._opts.model,
"speech_sample_rate": self._opts.speech_sample_rate,
"output_audio_codec": self._opts.output_audio_codec,
}
if self._opts.model == "bulbul:v2":
data["pitch"] = self._opts.pitch
data["loudness"] = self._opts.loudness
data["enable_preprocessing"] = self._opts.enable_preprocessing
if self._opts.enable_cached_responses is not None:
data["enable_cached_responses"] = self._opts.enable_cached_responses
if self._opts.model in ("bulbul:v3", "bulbul:v3-beta"):
data["temperature"] = self._opts.temperature
data["output_audio_bitrate"] = self._opts.output_audio_bitrate
data["min_buffer_size"] = self._opts.min_buffer_size
data["max_chunk_length"] = self._opts.max_chunk_length
if self._opts.model == "bulbul:v3" and self._opts.dict_id is not None:
data["dict_id"] = self._opts.dict_id
config_msg = {"type": "config", "data": data}
logger.debug(
"Sending TTS config", extra={**self._build_log_context(), "config": config_msg}
)
await ws.send_str(json.dumps(config_msg))
input_sent_event.set()
started = False
text_chunks_sent = 0
async for word in word_stream:
if not started:
self._mark_started()
started = True
text_msg = {"type": "text", "data": {"text": word.token}}
await ws.send_str(json.dumps(text_msg))
text_chunks_sent += 1
flush_msg = {"type": "flush"}
await ws.send_str(json.dumps(flush_msg))
except (ConnectionResetError, RuntimeError) as e:
err_str = str(e).lower()
if "closing" in err_str or "closed" in err_str:
# The transport is dead -- almost always a stale pooled
# connection that the server already closed (60s idle
# timeout). Raise so the pool evicts it via the
# ``async with`` __aexit__; the framework will retry
# against a fresh connection. Real user interruptions
# propagate as ``CancelledError``, not this branch.
logger.debug(
"Sarvam TTS WebSocket transport closed before send "
"completed; pool will replace this connection",
extra=self._build_log_context(),
)
raise APIConnectionError(
"Sarvam TTS WebSocket transport closed before send completed"
) from e
logger.error(
f"Error in send task: {e}", extra=self._build_log_context(), exc_info=True
)
raise APIConnectionError(f"Send task failed: {e}") from e
except Exception as e:
logger.error(
f"Error in send task: {e}", extra=self._build_log_context(), exc_info=True
)
raise APIConnectionError(f"Send task failed: {e}") from e
finally:
input_sent_event.set()
async def recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
await input_sent_event.wait()
try:
while True:
msg = await ws.receive(timeout=self._conn_options.timeout)
if msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
):
close_code = ws.close_code if ws.close_code is not None else msg.data
close_reason = msg.extra
is_expected_close = close_code in (1000, 1001, None)
if not is_expected_close:
logger.error(
"WebSocket connection closed by server",
extra={
**self._build_log_context(),
"close_code": close_code,
"close_reason": close_reason,
},
)
raw_close = {
"msg_type": str(msg.type),
"close_code": close_code,
"close_reason": close_reason,
}
raise APIStatusError(
message=(
"Sarvam TTS WebSocket closed with non-graceful status: "
f"{json.dumps(raw_close, ensure_ascii=False)}"
),
status_code=int(close_code) if isinstance(close_code, int) else -1,
body=raw_close,
)
logger.info(
"WebSocket connection closed by server",
extra={
**self._build_log_context(),
"close_code": close_code,
"close_reason": close_reason,
},
)
break
if msg.type == aiohttp.WSMsgType.TEXT:
success = await self._handle_websocket_message(msg.data, output_emitter)
if not success:
break
elif msg.type == aiohttp.WSMsgType.ERROR:
error_msg = f"WebSocket error: {msg.data}"
logger.error(error_msg, extra=self._build_log_context())
raise APIConnectionError(error_msg)
except asyncio.TimeoutError as e:
logger.error("WebSocket received timeout", extra=self._build_log_context())
raise APITimeoutError("WebSocket receive timeout") from e
except (APIStatusError, APIConnectionError, APITimeoutError):
raise
except ConnectionResetError as e:
err_str = str(e).lower()
if "closing" in err_str or "closed" in err_str:
# Same reasoning as ``send_task``: the transport is dead
# (typically a stale pooled connection). Raise so the
# pool evicts it and the framework retries against a
# fresh connection. Real interruptions surface as
# ``CancelledError``.
logger.debug(
"Sarvam TTS WebSocket transport closed during"
" receive; pool will replace this connection",
extra=self._build_log_context(),
)
raise APIConnectionError(
"Sarvam TTS WebSocket transport closed during receive"
) from e
logger.error(
f"Error in receive task: {e}", extra=self._build_log_context(), exc_info=True
)
raise
except Exception as e:
logger.error(
f"Error in receive task: {e}", extra=self._build_log_context(), exc_info=True
)
raise
try:
async with self._tts._pool.connection(timeout=self._conn_options.timeout) as ws:
# Pause the keepalive task while we own the connection so its
# ping frames don't interleave with config / text / flush
# traffic. The task was started either by ``_connect_ws`` (for
# a fresh connection) or by the previous ``_run_ws`` invocation
# that returned this connection to the pool.
await self._tts._stop_keepalive(ws)
keepalive_should_resume = False
try:
self._acquire_time = self._tts._pool.last_acquire_time
self._connection_reused = self._tts._pool.last_connection_reused
self._ws_conn = ws
self._connection_state = ConnectionState.CONNECTED
logger.info("WebSocket connected successfully", extra=self._build_log_context())
self._send_task = asyncio.create_task(send_task(ws))
self._recv_task = asyncio.create_task(recv_task(ws))
tasks = [self._send_task, self._recv_task]
try:
await asyncio.gather(*tasks)
logger.info(
"WebSocket session completed successfully",
extra=self._build_log_context(),
)
keepalive_should_resume = True
finally:
input_sent_event.set()
await utils.aio.gracefully_cancel(*tasks)
self._send_task = None
self._recv_task = None
finally:
# Resume the keepalive only when the session completed
# cleanly. On exception the pool will discard the
# connection via ``remove(conn)`` and ``_close_ws`` will
# run, so restarting here would be wasted work (and the
# task would be cancelled immediately anyway).
if keepalive_should_resume:
self._tts._start_keepalive(ws)
except (aiohttp.ClientConnectorError, asyncio.TimeoutError) as e:
self._connection_state = ConnectionState.FAILED
logger.error(f"Connection failed: {e}", extra=self._build_log_context())
raise APIConnectionError(f"Failed to connect to TTS WebSocket: {e}") from e
except (APIStatusError, APIConnectionError, APITimeoutError):
self._connection_state = ConnectionState.FAILED
raise
except Exception as e:
self._connection_state = ConnectionState.FAILED
logger.error(
f"Unexpected error in WebSocket session: {e}",
extra=self._build_log_context(),
exc_info=True,
)
raise APIStatusError(f"TTS WebSocket session failed: {e}") from e
finally:
self._connection_state = ConnectionState.DISCONNECTED
self._ws_conn = None
async def _handle_websocket_message(
self, msg_data: str, output_emitter: tts.AudioEmitter
) -> bool:
"""Handle WebSocket message with proper error handling.
Returns:
True if processing should continue, False if stream should end
"""
try:
resp = json.loads(msg_data)
msg_type = resp.get("type")
self._maybe_set_server_request_id(resp)
if self._server_request_id:
# Expose the server-assigned request id on the tts_request_run span so
# users can correlate traces with Sarvam's logs for debugging. Deduped
# internally, so calling on every message is fine.
output_emitter._note_provider_request_id(self._server_request_id)
if not msg_type:
logger.warning(
"Received message without type field",
extra={**self._build_log_context(), "data": resp},
)
return True
# logger.debug(f"Processing message type: {msg_type}", extra=self._build_log_context())
if msg_type == "audio":
return await self._handle_audio_message(resp, output_emitter)
elif msg_type == "error":
await self._handle_error_message(resp)
return False # Stop processing on error
elif msg_type == "event":
return await self._handle_event_message(resp, output_emitter)
else:
logger.debug(f"Unknown message type: {msg_type}", extra=self._build_log_context())
return True
except json.JSONDecodeError as e:
logger.warning(
f"Invalid JSON in WebSocket message: {e}",
extra={**self._build_log_context(), "raw_data": msg_data[:200]},
)
return True # Continue processing
except (APIStatusError, APIConnectionError):
# Preserve provider-originated status/body/retry metadata.
raise
except Exception as e:
logger.error(
f"Error processing WebSocket message: {e}",
extra=self._build_log_context(),
exc_info=True,
)
raise APIStatusError(
message=f"Message processing error: {e}. Raw server message: {msg_data}",
body={"raw_message": msg_data},
) from e
async def _handle_audio_message(self, resp: dict, output_emitter: tts.AudioEmitter) -> bool:
"""Handle audio message with proper error handling."""
try:
audio_data = resp.get("data", {}).get("audio", "")
if not audio_data:
logger.debug("Received empty audio data", extra=self._build_log_context())
return True
audio_bytes = base64.b64decode(audio_data)
if self._opts.output_audio_codec in _TELEPHONY_CODECS:
audio_bytes = _decode_telephony(self._opts.output_audio_codec, audio_bytes)
output_emitter.push(audio_bytes)
return True
except Exception as e: # base64 decode error
logger.error(f"Invalid base64 audio data: {e}", extra=self._build_log_context())
# Don't stop processing for audio decode errors
return True
async def _handle_error_message(self, resp: dict) -> None:
"""Handle error messages from the API."""
error_data = resp.get("data", {})
error_msg = error_data.get("message", "Unknown error")
error_code = error_data.get("code", "unknown")
raw_error_message = json.dumps(resp, ensure_ascii=False, separators=(",", ":"))
logger.error(
f"TTS API error: {error_msg}",
extra={
**self._build_log_context(),
"error_code": error_code,
"error_message": error_msg,
"raw_message": resp,
},
)
# Determine if error is recoverable based on error code/type
recoverable_errors = ["rate_limit", "temporary_unavailable", "timeout"]
is_recoverable = any(err in str(error_msg).lower() for err in recoverable_errors)
if is_recoverable:
raise APIConnectionError(f"Recoverable TTS API error from Sarvam: {raw_error_message}")
else:
raise APIStatusError(
message=f"TTS API error from Sarvam: {raw_error_message}",
status_code=500,
body=resp,
)
async def _handle_event_message(self, resp: dict, output_emitter: tts.AudioEmitter) -> bool:
"""Handle event messages from the API."""
event_data = resp.get("data", {})
event_type = event_data.get("event_type")
self._maybe_set_server_request_id(event_data)
if event_type == "final":
logger.debug("Generation complete event received", extra=self._build_log_context())
output_emitter.end_input()
return False # Stop processing
else:
logger.debug(f"Unknown event type: {event_type}", extra=self._build_log_context())
return True
def _build_log_context(self) -> dict:
"""Build consistent logging context."""
return {
"session_id": self._session_id,
"connection_state": self._connection_state.value,
"model": self._opts.model,
"speaker": self._opts.speaker,
"client_request_id": self._client_request_id,
"server_request_id": self._server_request_id,
}
def _maybe_set_server_request_id(self, data: dict) -> None:
"""Capture server-assigned request_id once it is available."""
if self._server_request_id is not None:
return
request_id = None
if isinstance(data, dict):
request_id = data.get("request_id")
if request_id is None:
nested = data.get("data")
if isinstance(nested, dict):
request_id = nested.get("request_id")
metadata = data.get("metadata")
if request_id is None and isinstance(metadata, dict):
request_id = metadata.get("request_id")
if request_id:
self._server_request_id = str(request_id)
async def aclose(self) -> None:
"""Close the stream and cleanup resources.
The base class cancels ``_run`` (and therefore all child tasks
spawned inside ``_run_ws``). The ``ConnectionPool`` context manager
handles WebSocket cleanup on cancellation. We only need to close
the segments channel so that ``_process_segments`` unblocks.
"""
self._segments_ch.close()
await super().aclose()