Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@

_TELEPHONY_CODECS: frozenset[str] = frozenset({"mulaw", "alaw"})

# Sample rates accepted by the Sarvam streaming (WebSocket) API. The higher
# rates (32/44.1/48 kHz) exist for the REST API only (bulbul:v3), so a stream
# built with one of them would only fail once a connection is open.
_STREAMING_SAMPLE_RATES: frozenset[int] = frozenset({8000, 16000, 22050, 24000})

# REST-only sample rates; valid only for bulbul:v3 via synthesize().
_REST_ONLY_SAMPLE_RATES: frozenset[int] = frozenset({32000, 44100, 48000})


def _codec_to_mime_type(codec: str) -> str:
"""Map a Sarvam output_audio_codec value to the MIME type the framework decoder expects."""
Expand Down Expand Up @@ -216,6 +224,15 @@ def _decode_telephony(codec: str, data: bytes) -> bytes:
"tanya",
"shruti",
"kavitha",
"anand",
"tarun",
"sunny",
"mani",
"gokul",
"vijay",
"mohit",
"rehan",
"soham",
]

# Model-Speaker compatibility mapping
Expand Down Expand Up @@ -294,8 +311,6 @@ def _decode_telephony(codec: str, data: bytes) -> bytes:
"priya",
"neha",
"roopa",
"amelia",
"sophia",
"suhani",
"rupali",
"tanya",
Expand All @@ -317,6 +332,15 @@ def _decode_telephony(codec: str, data: bytes) -> bytes:
"aayan",
"ashutosh",
"advait",
"anand",
"tarun",
"sunny",
"mani",
"gokul",
"vijay",
"mohit",
"rehan",
"soham",
],
"all": [
"shubh",
Expand All @@ -342,8 +366,15 @@ def _decode_telephony(codec: str, data: bytes) -> bytes:
"aayan",
"ashutosh",
"advait",
"amelia",
"sophia",
"anand",
"tarun",
"sunny",
"mani",
"gokul",
"vijay",
"mohit",
"rehan",
"soham",
"suhani",
"rupali",
"tanya",
Expand Down Expand Up @@ -379,6 +410,17 @@ def validate_model_speaker_compatibility(model: str, speaker: str) -> bool:
return True


def _pace_range(model: str) -> tuple[float, float]:
"""Return the (min, max) pace accepted by the Sarvam API for the model.

bulbul:v3 and bulbul:v3-beta cap pace at 2.0, while the legacy bulbul:v2
model still accepts up to 3.0 (per the official Bulbul API docs).
"""
if model in ("bulbul:v3", "bulbul:v3-beta"):
return (0.5, 2.0)
return (0.3, 3.0)


@dataclass
class SarvamTTSOptions:
"""Options for the Sarvam.ai TTS service.
Expand All @@ -389,13 +431,13 @@ class SarvamTTSOptions:
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)
pace: Speech rate multiplier (0.5 to 2.0 for v3/v3-beta, 0.3 to 3.0 for v2)
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)
speech_sample_rate: Audio sample rate (8000, 16000, 22050, 24000, 32000, 44100, or 48000; streaming is capped at 24 kHz)
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)
Expand Down Expand Up @@ -441,7 +483,7 @@ class TTS(tts.TTS):
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)
pace: Speech rate multiplier (0.5 to 2.0 for v3/v3-beta, 0.3 to 3.0 for v2)
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)
Expand Down Expand Up @@ -514,8 +556,9 @@ def __init__(
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")
min_pace, max_pace = _pace_range(model)
if not min_pace <= pace <= max_pace:
raise ValueError(f"Pace must be between {min_pace} and {max_pace} for model '{model}'")
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:
Expand Down Expand Up @@ -771,6 +814,21 @@ def update_options(
f"Speaker '{self._opts.speaker}' incompatible with {self._opts.model}. "
f"Compatible speakers: {', '.join(compatible_speakers)}"
)
# A pace that was valid for the previous model may be out of range
# for the new one. Clamp it with a warning (same policy as pitch)
# so a model switch cannot carry an invalid pace to the API.
if self._opts.pace is not None:
min_pace, max_pace = _pace_range(self._opts.model)
if not min_pace <= self._opts.pace <= max_pace:
logger.warning(
"pace value %.2f is outside the range [%.2f, %.2f] for model '%s'; "
"clamping to nearest bound.",
self._opts.pace,
min_pace,
max_pace,
self._opts.model,
)
self._opts.pace = max(min_pace, min(max_pace, self._opts.pace))
if speaker is not None:
if not speaker.strip():
raise ValueError("Speaker cannot be empty")
Expand All @@ -795,8 +853,11 @@ def update_options(
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")
min_pace, max_pace = _pace_range(self._opts.model)
if not min_pace <= pace <= max_pace:
raise ValueError(
f"Pace must be between {min_pace} and {max_pace} for model '{self._opts.model}'"
)
self._opts.pace = pace
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

if loudness is not None:
Expand Down Expand Up @@ -852,6 +913,14 @@ def synthesize(
self, text: str, *, conn_options: APIConnectOptions | None = None
) -> ChunkedStream:
"""Synthesize text to audio using Sarvam.ai TTS API."""
if (
self._opts.speech_sample_rate in _REST_ONLY_SAMPLE_RATES
and self._opts.model != "bulbul:v3"
):
raise ValueError(
"Sample rates of 32, 44.1 and 48 kHz are only available for "
"bulbul:v3 through the REST API"
)
if conn_options is None:
conn_options = DEFAULT_API_CONNECT_OPTIONS
return ChunkedStream(tts=self, input_text=text, conn_options=conn_options)
Expand All @@ -860,6 +929,13 @@ def stream(
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> SynthesizeStream:
"""Create a streaming TTS session."""
if self._opts.speech_sample_rate not in _STREAMING_SAMPLE_RATES:
raise ValueError(
"Sarvam streaming supports sample rates of "
f"{', '.join(map(str, sorted(_STREAMING_SAMPLE_RATES)))} Hz; "
f"got {self._opts.speech_sample_rate}. Rates above 24 kHz are "
"only available through the REST API with bulbul:v3."
)
stream = SynthesizeStream(tts=self, conn_options=conn_options)
self._streams.add(stream)
return stream
Expand Down
192 changes: 192 additions & 0 deletions livekit-plugins/livekit-plugins-sarvam/tests/test_tts_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""Tests for the Sarvam TTS input validation against the live Bulbul API.

Covers the three divergences reported in livekit/agents#6774:

1. bulbul:v3 ships 37 documented speakers (nine were missing, two were
offered that the API rejects).
2. Sample rates above 24 kHz are REST-only; streaming must fail locally
instead of mid-session on the server.
3. pace is validated per model (0.5-2.0 for v3/v3-beta, 0.3-3.0 for v2).
"""

from __future__ import annotations

import pytest

from livekit.plugins.sarvam import tts

pytestmark = pytest.mark.unit

API_KEY = "test-key"

# Exactly the speaker list published in the Sarvam Bulbul docs for bulbul:v3.
V3_SPEAKERS = [
"shubh",
"aditya",
"ritu",
"priya",
"neha",
"rahul",
"pooja",
"rohan",
"simran",
"kavya",
"amit",
"dev",
"ishita",
"shreya",
"ratan",
"varun",
"manan",
"sumit",
"roopa",
"kabir",
"aayan",
"ashutosh",
"advait",
"anand",
"tanya",
"tarun",
"sunny",
"mani",
"gokul",
"vijay",
"shruti",
"suhani",
"mohit",
"kavitha",
"rehan",
"soham",
"rupali",
]


# ---------------------------------------------------------------------------
# Speaker / model compatibility
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("speaker", V3_SPEAKERS)
def test_v3_accepts_all_documented_speakers(speaker: str) -> None:
assert tts.validate_model_speaker_compatibility("bulbul:v3", speaker)


def test_v3_speaker_list_matches_docs() -> None:
assert set(tts.MODEL_SPEAKER_COMPATIBILITY["bulbul:v3"]["all"]) == set(V3_SPEAKERS)


@pytest.mark.parametrize("speaker", ["amelia", "sophia"])
def test_v3_rejects_speakers_not_in_docs(speaker: str) -> None:
assert not tts.validate_model_speaker_compatibility("bulbul:v3", speaker)


def test_v2_accepts_legacy_speakers() -> None:
for speaker in ["anushka", "manisha", "vidya", "arya", "abhilash", "karun", "hitesh"]:
assert tts.validate_model_speaker_compatibility("bulbul:v2", speaker)


def test_v2_rejects_v3_only_speakers() -> None:
assert not tts.validate_model_speaker_compatibility("bulbul:v2", "shubh")
assert not tts.validate_model_speaker_compatibility("bulbul:v2", "anand")


def test_v3_beta_keeps_international_voices() -> None:
# v3-beta is not covered by the current public docs; keep the voices the
# plugin already shipped for it rather than guessing at the live list.
assert tts.validate_model_speaker_compatibility("bulbul:v3-beta", "amelia")
assert tts.validate_model_speaker_compatibility("bulbul:v3-beta", "sophia")


# ---------------------------------------------------------------------------
# Per-model pace validation
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("model", ["bulbul:v3", "bulbul:v3-beta"])
@pytest.mark.parametrize("pace", [0.4, 2.5, 3.0])
def test_pace_out_of_range_for_v3_models_raises(model: str, pace: float) -> None:
with pytest.raises(ValueError, match="Pace must be between"):
tts.TTS(model=model, pace=pace, api_key=API_KEY)


@pytest.mark.parametrize("pace", [0.5, 1.0, 2.0])
def test_pace_boundaries_accepted_for_v3(pace: float) -> None:
tts.TTS(model="bulbul:v3", pace=pace, api_key=API_KEY)


@pytest.mark.parametrize("pace", [0.3, 1.0, 3.0])
def test_pace_range_accepted_for_v2(pace: float) -> None:
tts.TTS(model="bulbul:v2", pace=pace, api_key=API_KEY)


def test_pace_out_of_range_for_v2_raises() -> None:
with pytest.raises(ValueError, match="Pace must be between"):
tts.TTS(model="bulbul:v2", pace=3.1, api_key=API_KEY)


def test_pace_2_5_still_valid_on_v2() -> None:
# 2.5 is out of range for v3 but fine on the legacy model.
tts.TTS(model="bulbul:v2", pace=2.5, api_key=API_KEY)


def test_update_options_validates_pace_per_model() -> None:
instance = tts.TTS(model="bulbul:v3", api_key=API_KEY)
with pytest.raises(ValueError, match="Pace must be between"):
instance.update_options(pace=2.5)
instance.update_options(pace=1.5)
assert instance._opts.pace == 1.5


def test_update_options_model_switch_revalidates_pace() -> None:
instance = tts.TTS(model="bulbul:v2", pace=2.5, api_key=API_KEY)
with pytest.raises(ValueError, match="Pace must be between"):
instance.update_options(model="bulbul:v3", speaker="shubh", pace=2.5)


def test_update_options_model_switch_clamps_stale_pace() -> None:
# 2.5 is legal on v2 but not on v3; switching models without passing a new
# pace must clamp the stored value instead of sending it to the API.
instance = tts.TTS(model="bulbul:v2", pace=2.5, api_key=API_KEY)
instance.update_options(model="bulbul:v3", speaker="shubh")
assert instance._opts.pace == 2.0


def test_update_options_model_switch_keeps_in_range_pace() -> None:
instance = tts.TTS(model="bulbul:v2", pace=1.5, api_key=API_KEY)
instance.update_options(model="bulbul:v3", speaker="shubh")
assert instance._opts.pace == 1.5


# ---------------------------------------------------------------------------
# Sample rate gating: streaming vs REST
# ---------------------------------------------------------------------------


@pytest.mark.parametrize("sample_rate", [32000, 44100, 48000])
@pytest.mark.parametrize("model", ["bulbul:v3", "bulbul:v2"])
def test_stream_rejects_rest_only_sample_rates(model: str, sample_rate: int) -> None:
instance = tts.TTS(model=model, speech_sample_rate=sample_rate, api_key=API_KEY)
with pytest.raises(ValueError, match="streaming supports sample rates"):
instance.stream()


@pytest.mark.parametrize("sample_rate", [8000, 16000, 22050, 24000])
async def test_stream_accepts_streaming_sample_rates(sample_rate: int) -> None:
instance = tts.TTS(model="bulbul:v3", speech_sample_rate=sample_rate, api_key=API_KEY)
stream = instance.stream()
assert stream is not None
await stream.aclose()


async def test_synthesize_accepts_rest_only_rates_on_v3() -> None:
instance = tts.TTS(model="bulbul:v3", speech_sample_rate=48000, api_key=API_KEY)
stream = instance.synthesize("hello")
assert stream is not None
await stream.aclose()


@pytest.mark.parametrize("model", ["bulbul:v2", "bulbul:v3-beta"])
def test_synthesize_rejects_rest_only_rates_on_non_v3(model: str) -> None:
instance = tts.TTS(model=model, speech_sample_rate=48000, api_key=API_KEY)
with pytest.raises(ValueError, match="only available for bulbul:v3"):
instance.synthesize("hello")