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 +