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"})

_SARVAM_SAMPLE_RATES: tuple[int, ...] = (8000, 16000, 22050, 24000, 32000, 44100, 48000)
_SARVAM_STREAMING_SAMPLE_RATES: tuple[int, ...] = (8000, 16000, 22050, 24000)
_MODEL_PACE_RANGES: dict[str, tuple[float, float]] = {
"bulbul:v2": (0.3, 3.0),
"bulbul:v3-beta": (0.5, 2.0),
"bulbul:v3": (0.5, 2.0),
}


def _codec_to_mime_type(codec: str) -> str:
"""Map a Sarvam output_audio_codec value to the MIME type the framework decoder expects."""
Expand All @@ -108,6 +116,23 @@ def _codec_to_mime_type(codec: str) -> str:
return mime


def _validate_pace(model: str, pace: float) -> None:
"""Validate pace against the range supported by the selected model."""
min_pace, max_pace = _MODEL_PACE_RANGES.get(model, (0.3, 3.0))
if not min_pace <= pace <= max_pace:
raise ValueError(f"Pace for {model} must be between {min_pace} and {max_pace}")


def _validate_streaming_sample_rate(sample_rate: int) -> None:
"""Validate a sample rate before opening a Sarvam streaming connection."""
if sample_rate not in _SARVAM_STREAMING_SAMPLE_RATES:
supported = ", ".join(str(rate) for rate in _SARVAM_STREAMING_SAMPLE_RATES)
raise ValueError(
f"Sarvam streaming TTS supports only {supported} Hz; "
"32000, 44100, and 48000 Hz are available through synthesize() only"
)


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)
Expand Down Expand Up @@ -211,6 +236,15 @@ def _decode_telephony(codec: str, data: bytes) -> bytes:
"amelia",
"sophia",
# bulbul:v3
"anand",
"tarun",
"sunny",
"mani",
"gokul",
"vijay",
"mohit",
"rehan",
"soham",
"suhani",
"rupali",
"tanya",
Expand Down Expand Up @@ -294,8 +328,6 @@ def _decode_telephony(codec: str, data: bytes) -> bytes:
"priya",
"neha",
"roopa",
"amelia",
"sophia",
"suhani",
"rupali",
"tanya",
Expand All @@ -317,6 +349,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,13 +383,20 @@ def _decode_telephony(codec: str, data: bytes) -> bytes:
"aayan",
"ashutosh",
"advait",
"amelia",
"sophia",
"suhani",
"rupali",
"tanya",
"shruti",
"kavitha",
"anand",
"tarun",
"sunny",
"mani",
"gokul",
"vijay",
"mohit",
"rehan",
"soham",
],
},
}
Expand Down Expand Up @@ -389,13 +437,14 @@ 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.3 to 3.0 for v2, 0.5 to 2.0 for v3/v3-beta)
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. REST synthesis supports 8000, 16000, 22050,
24000, 32000, 44100, and 48000 Hz; streaming supports the first four.
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 @@ -438,10 +487,11 @@ class TTS(tts.TTS):
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
speech_sample_rate: Audio sample rate in Hz. REST synthesis supports all Sarvam
rates; streaming supports 8000, 16000, 22050, and 24000 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.3 to 3.0 for v2, 0.5 to 2.0 for v3/v3-beta)
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 +564,7 @@ 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")
_validate_pace(model, pace)
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 All @@ -528,7 +577,7 @@ def __init__(
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]:
if speech_sample_rate not in _SARVAM_SAMPLE_RATES:
raise ValueError(
"Sample rate must be one of 8000, 16000, 22050, 24000, 32000, 44100, or 48000 Hz"
)
Expand Down Expand Up @@ -761,6 +810,7 @@ def update_options(
if model is not None:
if not model.strip():
raise ValueError("Model cannot be empty")
_validate_pace(model, self._opts.pace if pace is None else pace)
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):
Expand Down Expand Up @@ -795,8 +845,7 @@ 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")
_validate_pace(self._opts.model, pace)
self._opts.pace = pace

if loudness is not None:
Expand Down Expand Up @@ -860,6 +909,7 @@ def stream(
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> SynthesizeStream:
"""Create a streaming TTS session."""
_validate_streaming_sample_rate(self._opts.speech_sample_rate)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
stream = SynthesizeStream(tts=self, conn_options=conn_options)
self._streams.add(stream)
return stream
Expand Down
109 changes: 109 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,109 @@
from __future__ import annotations

import pytest

from livekit.plugins.sarvam.tts import TTS, validate_model_speaker_compatibility

pytestmark = pytest.mark.unit


@pytest.mark.parametrize(
"speaker",
["anand", "tarun", "sunny", "mani", "gokul", "vijay", "mohit", "rehan", "soham"],
)
def test_bulbul_v3_accepts_documented_speakers(speaker: str) -> None:
assert validate_model_speaker_compatibility("bulbul:v3", speaker)


async def test_tts_initialization_accepts_a_documented_bulbul_v3_speaker() -> None:
tts = TTS(api_key="test-key", model="bulbul:v3", speaker="anand")
try:
assert tts._opts.speaker == "anand"
finally:
await tts.aclose()


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


@pytest.mark.parametrize("speaker", ["amelia", "sophia"])
def test_tts_rejects_bulbul_v3_speakers_not_supported_by_the_model(speaker: str) -> None:
with pytest.raises(ValueError, match="not compatible with model 'bulbul:v3'"):
TTS(api_key="test-key", model="bulbul:v3", speaker=speaker)


def test_bulbul_v3_beta_keeps_international_speakers() -> None:
assert validate_model_speaker_compatibility("bulbul:v3-beta", "amelia")
assert validate_model_speaker_compatibility("bulbul:v3-beta", "sophia")


@pytest.mark.parametrize(
("model", "pace"),
[
("bulbul:v2", 0.3),
("bulbul:v2", 3.0),
("bulbul:v3-beta", 0.5),
("bulbul:v3-beta", 2.0),
("bulbul:v3", 0.5),
("bulbul:v3", 2.0),
],
)
async def test_pace_accepts_the_bounds_for_each_model(model: str, pace: float) -> None:
tts = TTS(api_key="test-key", model=model, pace=pace)
try:
assert tts._opts.pace == pace
finally:
await tts.aclose()


@pytest.mark.parametrize(
("model", "pace"),
[
("bulbul:v3-beta", 0.49),
("bulbul:v3-beta", 2.01),
("bulbul:v3", 0.49),
("bulbul:v3", 2.01),
],
)
def test_pace_rejects_values_outside_v3_model_range(model: str, pace: float) -> None:
with pytest.raises(ValueError, match=rf"Pace for {model} must be between 0.5 and 2.0"):
TTS(api_key="test-key", model=model, pace=pace)


async def test_update_options_uses_the_selected_model_pace_range() -> None:
tts = TTS(api_key="test-key", model="bulbul:v3", pace=1.0)
try:
with pytest.raises(ValueError, match="Pace for bulbul:v3 must be between 0.5 and 2.0"):
tts.update_options(pace=2.5)
finally:
await tts.aclose()


async def test_update_options_rejects_a_model_switch_with_an_invalid_existing_pace() -> None:
tts = TTS(api_key="test-key", model="bulbul:v2", pace=3.0)
try:
with pytest.raises(ValueError, match="Pace for bulbul:v3 must be between 0.5 and 2.0"):
tts.update_options(model="bulbul:v3")
finally:
await tts.aclose()


@pytest.mark.parametrize("sample_rate", [32000, 44100, 48000])
async def test_rest_only_sample_rates_are_rejected_before_streaming(sample_rate: int) -> None:
tts = TTS(api_key="test-key", speech_sample_rate=sample_rate)
try:
with pytest.raises(ValueError, match="streaming TTS"):
tts.stream()
finally:
await tts.aclose()


@pytest.mark.parametrize("sample_rate", [32000, 44100, 48000])
async def test_rest_only_sample_rates_remain_valid_for_synthesis(sample_rate: int) -> None:
tts = TTS(api_key="test-key", speech_sample_rate=sample_rate)
try:
assert tts.sample_rate == sample_rate
finally:
await tts.aclose()