mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-21 00:24:55 +00:00
[Fix] Azure AI Speech - Ensure voice is mapped from request body -> SSML body , allow sending role and style (#15810)
* update map_openai_params * fix update voice transform * fix text_to_speech_provider_config * test_azure_ava_tts_with_custom_voice * test Azure AVA style, role sent * _build_express_as_element * docs custom params * build LANG * fix transform * fix transform * fix speech * docs update * docs azure ai speech
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"<mstts:express-as {express_as_attrs_str}>{content}</mstts:express-as>"
|
||||
|
||||
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"""
|
||||
<speak version='1.0' xml:lang='en-US'>
|
||||
<voice name='{azure_voice}'>
|
||||
<prosody rate='{rate}'>
|
||||
{escaped_input}
|
||||
</prosody>
|
||||
</voice>
|
||||
</speak>
|
||||
"""
|
||||
# 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"<prosody rate='{rate}'>{escaped_input}</prosody>"
|
||||
|
||||
# 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"""<speak version='1.0' {xmlns} xml:lang='en-US'>
|
||||
<voice name='{azure_voice}'{voice_lang_attr}>
|
||||
{voice_content}
|
||||
</voice>
|
||||
</speak>"""
|
||||
|
||||
return {
|
||||
"ssml_body": ssml_body,
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
@@ -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 "<speak" in ssml_body
|
||||
assert "<voice" in ssml_body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_azure_ava_tts_fable_voice_mapping():
|
||||
"""
|
||||
Test that when using OpenAI voice 'fable',
|
||||
it gets mapped to Azure voice 'en-GB-RyanNeural' in the SSML.
|
||||
"""
|
||||
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="fable",
|
||||
input="Testing voice mapping",
|
||||
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 mapped voice (en-GB-RyanNeural, not 'fable')
|
||||
assert ssml_body is not None
|
||||
assert "en-GB-RyanNeural" in ssml_body
|
||||
assert "fable" not in ssml_body.lower()
|
||||
assert "Testing voice mapping" in ssml_body
|
||||
assert "<speak" in ssml_body
|
||||
assert "<voice" in ssml_body
|
||||
|
||||
@@ -19,30 +19,32 @@ def test_map_openai_params_voice_mapping(azure_tts_config: AzureAVATextToSpeechC
|
||||
"""
|
||||
Test mapping OpenAI voice to Azure AVA voice
|
||||
"""
|
||||
optional_params = {"voice": "alloy"}
|
||||
optional_params = {}
|
||||
|
||||
mapped = azure_tts_config.map_openai_params(
|
||||
mapped_voice, mapped_params = azure_tts_config.map_openai_params(
|
||||
model="azure-tts",
|
||||
optional_params=optional_params,
|
||||
voice="alloy",
|
||||
drop_params=False
|
||||
)
|
||||
|
||||
assert mapped["voice"] == "en-US-JennyNeural"
|
||||
assert mapped_voice == "en-US-JennyNeural"
|
||||
|
||||
|
||||
def test_map_openai_params_custom_azure_voice(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
"""
|
||||
Test using custom Azure voice directly
|
||||
"""
|
||||
optional_params = {"voice": "en-GB-RyanNeural"}
|
||||
optional_params = {}
|
||||
|
||||
mapped = azure_tts_config.map_openai_params(
|
||||
mapped_voice, mapped_params = azure_tts_config.map_openai_params(
|
||||
model="azure-tts",
|
||||
optional_params=optional_params,
|
||||
voice="en-GB-RyanNeural",
|
||||
drop_params=False
|
||||
)
|
||||
|
||||
assert mapped["voice"] == "en-GB-RyanNeural"
|
||||
assert mapped_voice == "en-GB-RyanNeural"
|
||||
|
||||
|
||||
def test_map_openai_params_response_format(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
@@ -51,13 +53,13 @@ def test_map_openai_params_response_format(azure_tts_config: AzureAVATextToSpeec
|
||||
"""
|
||||
optional_params = {"response_format": "mp3"}
|
||||
|
||||
mapped = azure_tts_config.map_openai_params(
|
||||
mapped_voice, mapped_params = azure_tts_config.map_openai_params(
|
||||
model="azure-tts",
|
||||
optional_params=optional_params,
|
||||
drop_params=False
|
||||
)
|
||||
|
||||
assert mapped["output_format"] == "audio-24khz-48kbitrate-mono-mp3"
|
||||
assert mapped_params["output_format"] == "audio-24khz-48kbitrate-mono-mp3"
|
||||
|
||||
|
||||
def test_map_openai_params_default_format(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
@@ -66,13 +68,13 @@ def test_map_openai_params_default_format(azure_tts_config: AzureAVATextToSpeech
|
||||
"""
|
||||
optional_params = {}
|
||||
|
||||
mapped = azure_tts_config.map_openai_params(
|
||||
mapped_voice, mapped_params = azure_tts_config.map_openai_params(
|
||||
model="azure-tts",
|
||||
optional_params=optional_params,
|
||||
drop_params=False
|
||||
)
|
||||
|
||||
assert mapped["output_format"] == "audio-24khz-48kbitrate-mono-mp3"
|
||||
assert mapped_params["output_format"] == "audio-24khz-48kbitrate-mono-mp3"
|
||||
|
||||
|
||||
def test_map_openai_params_speed(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
@@ -81,14 +83,14 @@ def test_map_openai_params_speed(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
"""
|
||||
optional_params = {"speed": 1.5}
|
||||
|
||||
mapped = azure_tts_config.map_openai_params(
|
||||
mapped_voice, mapped_params = azure_tts_config.map_openai_params(
|
||||
model="azure-tts",
|
||||
optional_params=optional_params,
|
||||
drop_params=False
|
||||
)
|
||||
|
||||
# Speed 1.5 should map to +50%
|
||||
assert mapped["rate"] == "+50%"
|
||||
assert mapped_params["rate"] == "+50%"
|
||||
|
||||
|
||||
def test_map_openai_params_slow_speed(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
@@ -97,14 +99,14 @@ def test_map_openai_params_slow_speed(azure_tts_config: AzureAVATextToSpeechConf
|
||||
"""
|
||||
optional_params = {"speed": 0.5}
|
||||
|
||||
mapped = azure_tts_config.map_openai_params(
|
||||
mapped_voice, mapped_params = azure_tts_config.map_openai_params(
|
||||
model="azure-tts",
|
||||
optional_params=optional_params,
|
||||
drop_params=False
|
||||
)
|
||||
|
||||
# Speed 0.5 should map to -50%
|
||||
assert mapped["rate"] == "-50%"
|
||||
assert mapped_params["rate"] == "-50%"
|
||||
|
||||
|
||||
# Tests for get_complete_url
|
||||
@@ -282,3 +284,270 @@ def test_transform_text_to_speech_response(azure_tts_config: AzureAVATextToSpeec
|
||||
from litellm.types.llms.openai import HttpxBinaryResponseContent
|
||||
assert isinstance(result, HttpxBinaryResponseContent)
|
||||
|
||||
|
||||
# Tests for helper methods
|
||||
def test_build_express_as_element_with_style(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
"""
|
||||
Test _build_express_as_element helper with style only
|
||||
"""
|
||||
result = azure_tts_config._build_express_as_element(
|
||||
content="<prosody rate='+0%'>Test</prosody>",
|
||||
style="cheerful"
|
||||
)
|
||||
|
||||
assert result == "<mstts:express-as style='cheerful'><prosody rate='+0%'>Test</prosody></mstts:express-as>"
|
||||
|
||||
|
||||
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="<prosody rate='+0%'>Test</prosody>",
|
||||
style="cheerful",
|
||||
styledegree="2",
|
||||
role="SeniorFemale"
|
||||
)
|
||||
|
||||
assert "<mstts:express-as" in result
|
||||
assert "style='cheerful'" in result
|
||||
assert "styledegree='2'" in result
|
||||
assert "role='SeniorFemale'" in result
|
||||
assert "<prosody rate='+0%'>Test</prosody>" in result
|
||||
assert "</mstts:express-as>" 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 = "<prosody rate='+0%'>Test</prosody>"
|
||||
result = azure_tts_config._build_express_as_element(content=content)
|
||||
|
||||
assert result == content
|
||||
assert "<mstts:express-as" not in result
|
||||
|
||||
|
||||
def test_get_voice_language_with_explicit_lang(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
"""
|
||||
Test _get_voice_language returns explicit language when provided
|
||||
"""
|
||||
result = azure_tts_config._get_voice_language(
|
||||
voice_name="en-US-AvaMultilingualNeural",
|
||||
explicit_lang="es-ES"
|
||||
)
|
||||
|
||||
assert result == "es-ES"
|
||||
|
||||
|
||||
def test_get_voice_language_without_explicit_lang(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
"""
|
||||
Test _get_voice_language returns None when no explicit language provided
|
||||
"""
|
||||
result = azure_tts_config._get_voice_language(
|
||||
voice_name="en-US-AriaNeural",
|
||||
explicit_lang=None
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_voice_language_explicit_takes_precedence(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
"""
|
||||
Test that explicit language takes precedence over voice name
|
||||
"""
|
||||
result = azure_tts_config._get_voice_language(
|
||||
voice_name="en-US-AvaMultilingualNeural",
|
||||
explicit_lang="fr-FR"
|
||||
)
|
||||
|
||||
assert result == "fr-FR"
|
||||
|
||||
|
||||
# Tests for Azure-specific SSML features
|
||||
def test_map_openai_params_style(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
"""
|
||||
Test passing through Azure style parameter
|
||||
"""
|
||||
optional_params = {"style": "cheerful"}
|
||||
|
||||
mapped_voice, mapped_params = azure_tts_config.map_openai_params(
|
||||
model="azure-tts",
|
||||
optional_params=optional_params,
|
||||
drop_params=False
|
||||
)
|
||||
|
||||
assert mapped_params["style"] == "cheerful"
|
||||
|
||||
|
||||
def test_map_openai_params_style_and_role(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
"""
|
||||
Test passing through Azure style, styledegree, and role parameters
|
||||
"""
|
||||
optional_params = {
|
||||
"style": "cheerful",
|
||||
"styledegree": "2",
|
||||
"role": "SeniorFemale"
|
||||
}
|
||||
|
||||
mapped_voice, mapped_params = azure_tts_config.map_openai_params(
|
||||
model="azure-tts",
|
||||
optional_params=optional_params,
|
||||
drop_params=False
|
||||
)
|
||||
|
||||
assert mapped_params["style"] == "cheerful"
|
||||
assert mapped_params["styledegree"] == "2"
|
||||
assert mapped_params["role"] == "SeniorFemale"
|
||||
|
||||
|
||||
def test_map_openai_params_lang(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
"""
|
||||
Test passing through Azure lang parameter for multilingual voices
|
||||
"""
|
||||
optional_params = {"lang": "es-ES"}
|
||||
|
||||
mapped_voice, mapped_params = azure_tts_config.map_openai_params(
|
||||
model="azure-tts",
|
||||
optional_params=optional_params,
|
||||
drop_params=False
|
||||
)
|
||||
|
||||
assert mapped_params["lang"] == "es-ES"
|
||||
|
||||
|
||||
def test_transform_text_to_speech_request_with_style(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
"""
|
||||
Test SSML generation with style parameter includes mstts:express-as element
|
||||
"""
|
||||
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",
|
||||
"style": "cheerful"
|
||||
},
|
||||
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 style
|
||||
assert "<mstts:express-as style='cheerful'>" in ssml
|
||||
assert "</mstts:express-as>" 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 "<mstts:express-as" in ssml
|
||||
assert "style='cheerful'" in ssml
|
||||
assert "styledegree='2'" in ssml
|
||||
assert "role='SeniorFemale'" in ssml
|
||||
assert "</mstts:express-as>" 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 "<mstts:express-as" not in ssml
|
||||
|
||||
# Should still include basic SSML structure
|
||||
assert "<speak" in ssml
|
||||
assert "<voice" in ssml
|
||||
assert "en-US-AriaNeural" in ssml
|
||||
assert "Hello world" in ssml
|
||||
|
||||
|
||||
def test_transform_text_to_speech_request_with_lang(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
"""
|
||||
Test SSML generation with lang parameter for multilingual voices
|
||||
"""
|
||||
result = azure_tts_config.transform_text_to_speech_request(
|
||||
model="azure-tts",
|
||||
input="Hola mundo",
|
||||
voice="en-US-AvaMultilingualNeural",
|
||||
optional_params={
|
||||
"voice": "en-US-AvaMultilingualNeural",
|
||||
"lang": "es-ES"
|
||||
},
|
||||
litellm_params={},
|
||||
headers={}
|
||||
)
|
||||
|
||||
ssml = result["ssml_body"]
|
||||
|
||||
# Should include xml:lang on voice element
|
||||
assert "xml:lang='es-ES'" in ssml
|
||||
|
||||
# Should still include the content and voice
|
||||
assert "Hola mundo" in ssml
|
||||
assert "en-US-AvaMultilingualNeural" in ssml
|
||||
|
||||
|
||||
def test_transform_text_to_speech_request_without_lang(azure_tts_config: AzureAVATextToSpeechConfig):
|
||||
"""
|
||||
Test that SSML without lang parameter does not include xml:lang on voice element
|
||||
"""
|
||||
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"]
|
||||
|
||||
# Voice element should not have xml:lang attribute (only the speak element should)
|
||||
# Check that voice element doesn't have xml:lang by ensuring the pattern doesn't exist
|
||||
assert "<voice name='en-US-AriaNeural' xml:lang=" not in ssml
|
||||
assert "<voice name='en-US-AriaNeural'>" in ssml
|
||||
|
||||
|
||||
Reference in New Issue
Block a user