diff --git a/docs/my-website/docs/providers/azure_ai_speech.md b/docs/my-website/docs/providers/azure_ai_speech.md
index d358af4c6c..434a796a2f 100644
--- a/docs/my-website/docs/providers/azure_ai_speech.md
+++ b/docs/my-website/docs/providers/azure_ai_speech.md
@@ -136,6 +136,168 @@ response = speech(
| `wav` | riff-24khz-16bit-mono-pcm | 24kHz |
| `pcm` | raw-24khz-16bit-mono-pcm | 24kHz |
+## Sending Azure-Specific Params
+
+Azure AI Speech supports advanced SSML features through optional parameters:
+
+- `style`: Speaking style (e.g., "cheerful", "sad", "angry", "whispering")
+- `styledegree`: Style intensity (0.01 to 2)
+- `role`: Voice role (e.g., "Girl", "Boy", "SeniorFemale", "SeniorMale")
+- `lang`: Language code for multilingual voices (e.g., "es-ES", "fr-FR", "hi-IN")
+
+### **LiteLLM SDK**
+
+#### Custom Azure Voice
+
+```python showLineNumbers title="Custom Azure Voice"
+from litellm import speech
+
+response = speech(
+ model="azure/speech/azure-tts",
+ voice="en-US-AndrewNeural", # Use Azure voice directly
+ input="Hello, this is a test",
+ api_base="https://eastus.tts.speech.microsoft.com",
+ api_key=os.environ["AZURE_TTS_API_KEY"],
+ response_format="mp3"
+)
+response.stream_to_file("speech.mp3")
+```
+
+#### Speaking Style
+
+```python showLineNumbers title="Speaking Style"
+from litellm import speech
+
+response = speech(
+ model="azure/speech/azure-tts",
+ voice="en-US-JennyNeural", # Must be a voice that supports styles
+ input="Who are you? What is chicken dinner?",
+ api_base="https://eastus.tts.speech.microsoft.com",
+ api_key=os.environ["AZURE_TTS_API_KEY"],
+ style="whispering", # Azure-specific: cheerful, sad, angry, whispering, etc.
+)
+response.stream_to_file("speech.mp3")
+```
+
+#### Style with Degree and Role
+
+```python showLineNumbers title="Style with Degree and Role"
+from litellm import speech
+
+response = speech(
+ model="azure/speech/azure-tts",
+ voice="en-US-AriaNeural",
+ input="Good morning! How are you today?",
+ api_base="https://eastus.tts.speech.microsoft.com",
+ api_key=os.environ["AZURE_TTS_API_KEY"],
+ style="cheerful", # Azure-specific: Speaking style
+ styledegree="2", # Azure-specific: 0.01 to 2 (intensity)
+ role="SeniorFemale", # Azure-specific: Girl, Boy, SeniorFemale, etc.
+)
+response.stream_to_file("speech.mp3")
+```
+
+#### Language Override for Multilingual Voices
+
+```python showLineNumbers title="Language Override"
+from litellm import speech
+
+response = speech(
+ model="azure/speech/azure-tts",
+ voice="en-US-AvaMultilingualNeural", # Multilingual voice
+ input="आप कौन हैं? चिकन डिनर क्या है?", # Hindi text
+ api_base="https://eastus.tts.speech.microsoft.com",
+ api_key=os.environ["AZURE_TTS_API_KEY"],
+ lang="hi-IN", # Azure-specific: Override language
+)
+response.stream_to_file("speech.mp3")
+```
+
+### **LiteLLM AI Gateway (CURL)**
+
+First, ensure you have set up your proxy config as shown in the [LiteLLM Proxy setup](#quick-start) above.
+
+**Using the model name from your config:**
+
+```yaml
+model_list:
+ - model_name: azure-speech # This is what you'll use in your API calls
+ litellm_params:
+ model: azure/speech/azure-tts
+ api_base: https://eastus.tts.speech.microsoft.com
+ api_key: os.environ/AZURE_TTS_API_KEY
+```
+
+#### Custom Azure Voice
+
+```bash
+curl http://0.0.0.0:4000/v1/audio/speech \
+ -H "Authorization: Bearer sk-1234" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "azure-speech",
+ "voice": "en-US-AndrewNeural",
+ "input": "Hello, this is a test"
+ }' \
+ --output speech.mp3
+```
+
+#### Speaking Style
+
+```bash
+curl http://0.0.0.0:4000/v1/audio/speech \
+ -H "Authorization: Bearer sk-1234" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "azure-speech",
+ "input": "Who are you? What is chicken dinner?",
+ "voice": "en-US-JennyNeural",
+ "style": "whispering"
+ }' \
+ --output speech.mp3
+```
+
+#### Style with Degree and Role
+
+```bash
+curl http://0.0.0.0:4000/v1/audio/speech \
+ -H "Authorization: Bearer sk-1234" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "azure-speech",
+ "voice": "en-US-AriaNeural",
+ "input": "Good morning! How are you today?",
+ "style": "cheerful",
+ "styledegree": "2",
+ "role": "SeniorFemale"
+ }' \
+ --output speech.mp3
+```
+
+#### Language Override
+
+```bash
+curl http://0.0.0.0:4000/v1/audio/speech \
+ -H "Authorization: Bearer sk-1234" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "azure-speech",
+ "input": "आप कौन हैं? चिकन डिनर क्या है?",
+ "voice": "en-US-AvaMultilingualNeural",
+ "lang": "hi-IN"
+ }' \
+ --output speech.mp3
+```
+
+### Azure-Specific Parameters Reference
+
+| Parameter | Description | Example Values | Notes |
+|-----------|-------------|----------------|-------|
+| `style` | Speaking style | `cheerful`, `sad`, `angry`, `excited`, `friendly`, `hopeful`, `shouting`, `terrified`, `unfriendly`, `whispering` | Only supported by certain voices. See [Azure voice styles documentation](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-synthesis-markup-voice#use-speaking-styles-and-roles) |
+| `styledegree` | Style intensity | `0.01` to `2` | Higher values = more intense. Default is `1` |
+| `role` | Voice role | `Girl`, `Boy`, `YoungAdultFemale`, `YoungAdultMale`, `OlderAdultFemale`, `OlderAdultMale`, `SeniorFemale`, `SeniorMale` | Only supported by certain voices |
+| `lang` | Language code | `es-ES`, `fr-FR`, `de-DE`, `hi-IN`, etc. | For multilingual voices. Overrides the default language |
+
## Async Support
```python showLineNumbers title="Async Usage"
diff --git a/litellm/llms/azure/text_to_speech/transformation.py b/litellm/llms/azure/text_to_speech/transformation.py
index cfabd43ea2..0f8911ac2b 100644
--- a/litellm/llms/azure/text_to_speech/transformation.py
+++ b/litellm/llms/azure/text_to_speech/transformation.py
@@ -4,7 +4,7 @@ Azure AVA (Cognitive Services) Text-to-Speech transformation
Maps OpenAI TTS spec to Azure Cognitive Services TTS API
"""
-from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Union
+from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, Tuple, Union
from urllib.parse import urlparse
import httpx
@@ -32,6 +32,7 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
"""
# Azure endpoint domains
+ DEFAULT_VOICE = "en-US-AriaNeural"
COGNITIVE_SERVICES_DOMAIN = "api.cognitive.microsoft.com"
TTS_SPEECH_DOMAIN = "tts.speech.microsoft.com"
TTS_ENDPOINT_PATH = "/cognitiveservices/v1"
@@ -134,6 +135,9 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
def get_supported_openai_params(self, model: str) -> list:
"""
Azure AVA TTS supports these OpenAI parameters
+
+ Note: Azure also supports additional SSML-specific parameters (style, styledegree, role)
+ which can be passed but are not part of the OpenAI spec
"""
return ["voice", "response_format", "speed"]
@@ -154,28 +158,93 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
"""
rate_percentage = int((speed - 1.0) * 100)
return f"{rate_percentage:+d}%"
+
+ def _build_express_as_element(
+ self,
+ content: str,
+ style: Optional[str] = None,
+ styledegree: Optional[str] = None,
+ role: Optional[str] = None,
+ ) -> str:
+ """
+ Build mstts:express-as element with optional style, styledegree, and role attributes
+
+ Args:
+ content: The inner content to wrap
+ style: Speaking style (e.g., "cheerful", "sad", "angry")
+ styledegree: Style intensity (0.01 to 2)
+ role: Voice role (e.g., "Girl", "Boy", "SeniorFemale", "SeniorMale")
+
+ Returns:
+ Content wrapped in mstts:express-as if any attributes provided, otherwise raw content
+ """
+ if not (style or styledegree or role):
+ return content
+
+ express_as_attrs = []
+ if style:
+ express_as_attrs.append(f"style='{style}'")
+ if styledegree:
+ express_as_attrs.append(f"styledegree='{styledegree}'")
+ if role:
+ express_as_attrs.append(f"role='{role}'")
+
+ express_as_attrs_str = " ".join(express_as_attrs)
+ return f"{content}"
+
+ def _get_voice_language(
+ self,
+ voice_name: Optional[str],
+ explicit_lang: Optional[str] = None,
+ ) -> Optional[str]:
+ """
+ Get the language for the voice element's xml:lang attribute
+
+ Args:
+ voice_name: The Azure voice name (e.g., "en-US-AriaNeural")
+ explicit_lang: Explicitly provided language code (takes precedence)
+
+ Returns:
+ Language code if available (e.g., "es-ES"), or None
+
+ Examples:
+ - explicit_lang="es-ES" → "es-ES" (explicit takes precedence)
+ - voice_name="en-US-AriaNeural", explicit_lang=None → None (use default from voice)
+ - voice_name="en-US-AvaMultilingualNeural", explicit_lang="fr-FR" → "fr-FR"
+ """
+ # If explicit language is provided, use it (for multilingual voices)
+ if explicit_lang:
+ return explicit_lang
+
+ # For non-multilingual voices, we don't need to set xml:lang on the voice element
+ # The voice name already encodes the language (e.g., en-US-AriaNeural)
+ # Only return a language if explicitly set
+ return None
def map_openai_params(
self,
model: str,
optional_params: Dict,
- drop_params: bool,
- ) -> Dict:
+ voice: Optional[Union[str, Dict]] = None,
+ drop_params: bool = False,
+ kwargs: Dict = {},
+ ) -> Tuple[Optional[str], Dict]:
"""
Map OpenAI parameters to Azure AVA TTS parameters
"""
mapped_params = {}
-
+ ##########################################################
# Map voice
- if "voice" in optional_params:
- voice = optional_params["voice"]
- # If it's already an Azure voice, use it directly
- if isinstance(voice, str):
- if voice in self.VOICE_MAPPINGS:
- mapped_params["voice"] = self.VOICE_MAPPINGS[voice]
- else:
- # Assume it's already an Azure voice name
- mapped_params["voice"] = voice
+ # OpenAI uses voice as a required param, hence not in optional_params
+ ##########################################################
+ # If it's already an Azure voice, use it directly
+ mapped_voice: Optional[str] = None
+ if isinstance(voice, str):
+ if voice in self.VOICE_MAPPINGS:
+ mapped_voice = self.VOICE_MAPPINGS[voice]
+ else:
+ # Assume it's already an Azure voice name
+ mapped_voice = voice
# Map response format
if "response_format" in optional_params:
@@ -195,7 +264,19 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
if speed is not None:
mapped_params["rate"] = self._convert_speed_to_azure_rate(speed=speed)
- return mapped_params
+ # Pass through Azure-specific SSML parameters
+ if "style" in kwargs:
+ mapped_params["style"] = kwargs["style"]
+
+ if "styledegree" in kwargs:
+ mapped_params["styledegree"] = kwargs["styledegree"]
+
+ if "role" in kwargs:
+ mapped_params["role"] = kwargs["role"]
+
+ if "lang" in kwargs:
+ mapped_params["lang"] = kwargs["lang"]
+ return mapped_voice, mapped_params
def validate_environment(
self,
@@ -315,11 +396,17 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
Note: optional_params should already be mapped via map_openai_params in main.py
+ Supports Azure-specific SSML features:
+ - style: Speaking style (e.g., "cheerful", "sad", "angry")
+ - styledegree: Style intensity (0.01 to 2)
+ - role: Voice role (e.g., "Girl", "Boy", "SeniorFemale", "SeniorMale")
+ - lang: Language code for multilingual voices (e.g., "es-ES", "fr-FR")
+
Returns:
TextToSpeechRequestData: Contains SSML body and Azure-specific headers
"""
# Get voice (already mapped in main.py, or use default)
- azure_voice = optional_params.get("voice", "en-US-AriaNeural")
+ azure_voice = voice or self.DEFAULT_VOICE
# Get output format (already mapped in main.py)
output_format = optional_params.get(
@@ -329,6 +416,10 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
# Build SSML
rate = optional_params.get("rate", "0%")
+ style = optional_params.get("style")
+ styledegree = optional_params.get("styledegree")
+ role = optional_params.get("role")
+ lang = optional_params.get("lang")
# Escape XML special characters in input text
escaped_input = (
@@ -339,15 +430,38 @@ class AzureAVATextToSpeechConfig(BaseTextToSpeechConfig):
.replace("'", "'")
)
- ssml_body = f"""
-
-
-
- {escaped_input}
-
-
-
- """
+ # Determine if we need mstts namespace (for express-as element)
+ use_mstts = style or role or styledegree
+
+ # Build the xmlns attributes
+ if use_mstts:
+ xmlns = "xmlns='http://www.w3.org/2001/10/synthesis' xmlns:mstts='https://www.w3.org/2001/mstts'"
+ else:
+ xmlns = "xmlns='http://www.w3.org/2001/10/synthesis'"
+
+ # Build the inner content with prosody
+ prosody_content = f"{escaped_input}"
+
+ # Wrap in mstts:express-as if style or role is specified
+ voice_content = self._build_express_as_element(
+ content=prosody_content,
+ style=style,
+ styledegree=styledegree,
+ role=role,
+ )
+
+ # Build voice element with optional xml:lang attribute
+ voice_lang = self._get_voice_language(
+ voice_name=azure_voice,
+ explicit_lang=lang,
+ )
+ voice_lang_attr = f" xml:lang='{voice_lang}'" if voice_lang else ""
+
+ ssml_body = f"""
+
+ {voice_content}
+
+"""
return {
"ssml_body": ssml_body,
diff --git a/litellm/llms/base_llm/text_to_speech/transformation.py b/litellm/llms/base_llm/text_to_speech/transformation.py
index 8821133704..31f581cec0 100644
--- a/litellm/llms/base_llm/text_to_speech/transformation.py
+++ b/litellm/llms/base_llm/text_to_speech/transformation.py
@@ -1,6 +1,6 @@
import types
from abc import ABC, abstractmethod
-from typing import TYPE_CHECKING, Any, Dict, Optional, TypedDict
+from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, TypedDict, Union
import httpx
@@ -68,8 +68,10 @@ class BaseTextToSpeechConfig(ABC):
self,
model: str,
optional_params: Dict,
- drop_params: bool,
- ) -> Dict:
+ voice: Optional[Union[str, Dict]] = None,
+ drop_params: bool = False,
+ kwargs: Dict = {},
+ ) -> Tuple[Optional[str], Dict]:
"""
Map OpenAI TTS parameters to provider-specific parameters
"""
diff --git a/litellm/main.py b/litellm/main.py
index 36e769781b..8e3f9a0b3d 100644
--- a/litellm/main.py
+++ b/litellm/main.py
@@ -5706,17 +5706,19 @@ def speech( # noqa: PLR0915
# Map OpenAI params to provider-specific params if config exists
if text_to_speech_provider_config is not None:
- optional_params = text_to_speech_provider_config.map_openai_params(
+ voice, optional_params = text_to_speech_provider_config.map_openai_params(
model=model,
optional_params=optional_params,
+ voice=voice,
drop_params=False,
+ kwargs=kwargs,
)
logging_obj: Logging = cast(Logging, kwargs.get("litellm_logging_obj"))
logging_obj.update_environment_variables(
model=model,
user=user,
- optional_params={},
+ optional_params=optional_params,
litellm_params={
"litellm_call_id": litellm_call_id,
"proxy_server_request": proxy_server_request,
diff --git a/tests/audio_tests/test_audio_speech.py b/tests/audio_tests/test_audio_speech.py
index 8861686ab1..c0bb6f7280 100644
--- a/tests/audio_tests/test_audio_speech.py
+++ b/tests/audio_tests/test_audio_speech.py
@@ -376,6 +376,93 @@ async def test_azure_ava_tts_async():
# assert response cost is greater than 0
print("Response cost: ", response._hidden_params["response_cost"])
assert response._hidden_params["response_cost"] > 0
-
+
except Exception as e:
pytest.fail(f"Test failed with exception: {str(e)}")
+
+
+@pytest.mark.asyncio
+async def test_azure_ava_tts_with_custom_voice():
+ """
+ Test that when using a custom Azure voice (en-US-AndrewNeural),
+ the SSML request body contains the selected voice.
+ """
+ from unittest.mock import AsyncMock, MagicMock, patch
+ import httpx
+
+ # Mock response
+ mock_response_content = b"fake_audio_data"
+ mock_httpx_response = MagicMock(spec=httpx.Response)
+ mock_httpx_response.content = mock_response_content
+ mock_httpx_response.status_code = 200
+ mock_httpx_response.headers = {"content-type": "audio/mpeg"}
+
+ with patch("litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post") as mock_post:
+ mock_post.return_value = mock_httpx_response
+
+ response = await litellm.aspeech(
+ model="azure/speech/azure-tts",
+ voice="en-US-AndrewNeural",
+ input="Hello, this is a test",
+ api_base="https://eastus.tts.speech.microsoft.com",
+ api_key="fake-key",
+ response_format="mp3",
+ )
+
+ # Verify the mock was called
+ assert mock_post.called
+
+ # Get the call arguments
+ call_args = mock_post.call_args
+ ssml_body = call_args.kwargs.get("data")
+
+ # Verify the SSML contains the custom voice
+ assert ssml_body is not None
+ assert "en-US-AndrewNeural" in ssml_body
+ assert "Hello, this is a test" in ssml_body
+ assert "Test"
+
+
+def test_build_express_as_element_with_all_attrs(azure_tts_config: AzureAVATextToSpeechConfig):
+ """
+ Test _build_express_as_element helper with all attributes
+ """
+ result = azure_tts_config._build_express_as_element(
+ content="Test",
+ style="cheerful",
+ styledegree="2",
+ role="SeniorFemale"
+ )
+
+ assert "Test" in result
+ assert "" in result
+
+
+def test_build_express_as_element_no_attrs(azure_tts_config: AzureAVATextToSpeechConfig):
+ """
+ Test _build_express_as_element helper returns content unchanged when no attrs
+ """
+ content = "Test"
+ result = azure_tts_config._build_express_as_element(content=content)
+
+ assert result == content
+ assert "" in ssml
+ assert "" in ssml
+
+ # Should still include the content
+ assert "Hello world" in ssml
+ assert "en-US-AriaNeural" in ssml
+
+
+def test_transform_text_to_speech_request_with_style_degree_role(azure_tts_config: AzureAVATextToSpeechConfig):
+ """
+ Test SSML generation with style, styledegree, and role parameters
+ """
+ result = azure_tts_config.transform_text_to_speech_request(
+ model="azure-tts",
+ input="Test message",
+ voice="en-US-AriaNeural",
+ optional_params={
+ "voice": "en-US-AriaNeural",
+ "style": "cheerful",
+ "styledegree": "2",
+ "role": "SeniorFemale"
+ },
+ litellm_params={},
+ headers={}
+ )
+
+ ssml = result["ssml_body"]
+
+ # Should include mstts namespace
+ assert "xmlns:mstts='https://www.w3.org/2001/mstts'" in ssml
+
+ # Should include mstts:express-as with all attributes
+ assert "" in ssml
+
+
+def test_transform_text_to_speech_request_without_style(azure_tts_config: AzureAVATextToSpeechConfig):
+ """
+ Test that SSML without style does not include mstts namespace or express-as
+ """
+ result = azure_tts_config.transform_text_to_speech_request(
+ model="azure-tts",
+ input="Hello world",
+ voice="en-US-AriaNeural",
+ optional_params={"voice": "en-US-AriaNeural"},
+ litellm_params={},
+ headers={}
+ )
+
+ ssml = result["ssml_body"]
+
+ # Should NOT include mstts namespace
+ assert "xmlns:mstts" not in ssml
+
+ # Should NOT include mstts:express-as
+ assert "" in ssml
+