diff --git a/tests/tools/test_azure_tts.py b/tests/tools/test_azure_tts.py index a863c67c..cdc1df7d 100644 --- a/tests/tools/test_azure_tts.py +++ b/tests/tools/test_azure_tts.py @@ -6,6 +6,7 @@ construction, and execute() guardrails. """ import sys +import xml.etree.ElementTree as ET from pathlib import Path import pytest @@ -148,6 +149,20 @@ class TestSSML: ssml = t._build_ssml({"text": "Hallo", "locale": "de-DE"}, "de-DE-KatjaNeural") assert 'xml:lang="de-DE"' in ssml + def test_ssml_quotes_in_attributes_remain_well_formed(self): + t = AzureTTS() + ssml = t._build_ssml( + { + "text": 'She said "hello" & waved', + "locale": 'en-US" data-bad="yes', + "rate": '0%" data-bad="yes', + "style": 'calm" data-bad="yes', + }, + 'en-US-GuyNeural" data-bad="yes', + ) + root = ET.fromstring(ssml) + assert all("data-bad" not in element.attrib for element in root.iter()) + def test_host_prefers_explicit_endpoint(self, monkeypatch): monkeypatch.setenv("AZURE_TTS_ENDPOINT", "https://custom.tts.example.com/") assert AzureTTS()._host() == "https://custom.tts.example.com" @@ -201,6 +216,39 @@ class TestExecute: assert captured["headers"]["X-Microsoft-OutputFormat"] == "audio-48khz-192kbitrate-mono-mp3" assert b"Hello world" in captured["body"] + def test_selector_adapts_shared_controls_for_azure(self, azure_env, tmp_path, monkeypatch): + import requests + from tools.audio.tts_selector import TTSSelector + + captured = {} + + def fake_post(url, headers=None, data=None, timeout=None): + captured["body"] = data.decode("utf-8") + return _FakeResponse() + + monkeypatch.setattr(requests, "post", fake_post) + monkeypatch.setattr(TTSSelector, "_providers", lambda self: [AzureTTS()]) + + result = TTSSelector().execute( + { + "text": "Selector narration", + "preferred_provider": "azure", + "voice_id": "jenny", + "speaking_rate": 1.1, + "pitch": 2, + "style": 0.8, + "output_format": "mp3_44100_128", + "output_path": str(tmp_path / "selector.mp3"), + } + ) + + assert result.success + assert result.data["selected_tool"] == "azure_tts" + assert result.data["voice"] == "en-US-JennyNeural" + assert 'rate="+10%"' in captured["body"] + assert 'pitch="+2st"' in captured["body"] + assert "express-as" not in captured["body"] + def test_wav_output_format(self, azure_env, tmp_path, monkeypatch): import requests diff --git a/tools/audio/azure_tts.py b/tools/audio/azure_tts.py index 6943fed6..220dffc2 100644 --- a/tools/audio/azure_tts.py +++ b/tools/audio/azure_tts.py @@ -19,7 +19,7 @@ import os import time from pathlib import Path from typing import Any -from xml.sax.saxutils import escape +from xml.sax.saxutils import escape, quoteattr from tools.base_tool import ( BaseTool, @@ -201,23 +201,21 @@ class AzureTTS(BaseTool): return self.RECOMMENDED_VOICES.get(voice.lower(), voice) def _build_ssml(self, inputs: dict[str, Any], voice: str) -> str: - locale = inputs.get("locale", "en-US") - rate = inputs.get("rate", "0%") - pitch = inputs.get("pitch", "0%") + locale = str(inputs.get("locale", "en-US")) + rate = str(inputs.get("rate", "0%")) + pitch = str(inputs.get("pitch", "0%")) style = inputs.get("style") text = escape(inputs["text"]) - inner = f'{text}' + inner = f"{text}" if style: - inner = ( - f'{inner}' - ) + inner = f"{inner}" return ( f'' - f'{inner}' + f"xml:lang={quoteattr(locale)}>" + f"{inner}" ) def execute(self, inputs: dict[str, Any]) -> ToolResult: diff --git a/tools/audio/tts_selector.py b/tools/audio/tts_selector.py index 5c20cd57..614b4bac 100644 --- a/tools/audio/tts_selector.py +++ b/tools/audio/tts_selector.py @@ -188,7 +188,7 @@ class TTSSelector(BaseTool): if tool is None: return ToolResult(success=False, error="No TTS provider available.") - result = tool.execute(inputs) + result = tool.execute(self._adapt_inputs(tool, inputs)) if result.success: result.data.setdefault("selected_tool", tool.name) result.data["selected_provider"] = tool.provider @@ -202,6 +202,37 @@ class TTSSelector(BaseTool): ] return result + @staticmethod + def _adapt_inputs(tool: BaseTool, inputs: dict[str, Any]) -> dict[str, Any]: + """Translate capability-level controls to provider-native inputs.""" + adapted = dict(inputs) + if tool.name != "azure_tts": + return adapted + + if inputs.get("voice_id") and not inputs.get("voice"): + adapted["voice"] = inputs["voice_id"] + + speed = inputs.get("speaking_rate", inputs.get("speed")) + if speed is not None and "rate" not in inputs: + percent = round((float(speed) - 1.0) * 100) + adapted["rate"] = f"{percent:+d}%" if percent else "0%" + + pitch = inputs.get("pitch") + if isinstance(pitch, (int, float)): + adapted["pitch"] = f"{pitch:+g}st" if pitch else "0%" + + # The selector's numeric style is ElevenLabs-specific. Azure's style + # is a named express-as value such as "calm" or "newscast". + if not isinstance(inputs.get("style"), str): + adapted.pop("style", None) + + output_format = str(inputs.get("output_format", "")) + if output_format.startswith("mp3"): + adapted["output_format"] = "mp3" + elif output_format.startswith(("wav", "riff", "pcm")): + adapted["output_format"] = "wav" + return adapted + def _select_best_tool( self, inputs: dict[str, Any],