Compare commits
1 Commits
queue/372-
...
queue/321-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d06becd3 |
52
tests/tools/test_tts_speed.py
Normal file
52
tests/tools/test_tts_speed.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Tests for TTS speed support (#321)."""
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch, AsyncMock
|
||||||
|
|
||||||
|
|
||||||
|
class TestTTSSchemaHasSpeed:
|
||||||
|
def test_schema_includes_speed(self):
|
||||||
|
from tools.tts_tool import TTS_SCHEMA
|
||||||
|
assert "speed" in TTS_SCHEMA["parameters"]["properties"]
|
||||||
|
assert TTS_SCHEMA["parameters"]["properties"]["speed"]["type"] == "number"
|
||||||
|
|
||||||
|
def test_speed_not_required(self):
|
||||||
|
from tools.tts_tool import TTS_SCHEMA
|
||||||
|
assert "speed" not in TTS_SCHEMA["parameters"].get("required", [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextToSpeechToolSignature:
|
||||||
|
def test_accepts_speed(self):
|
||||||
|
from tools.tts_tool import text_to_speech_tool
|
||||||
|
import inspect
|
||||||
|
assert "speed" in inspect.signature(text_to_speech_tool).parameters
|
||||||
|
|
||||||
|
|
||||||
|
class TestSpeedClamping:
|
||||||
|
@patch("tools.tts_tool._load_tts_config", return_value={})
|
||||||
|
@patch("tools.tts_tool._get_provider", return_value="edge")
|
||||||
|
@patch("tools.tts_tool._import_edge_tts")
|
||||||
|
def test_clamped_low(self, mock_edge, mock_prov, mock_cfg):
|
||||||
|
from tools.tts_tool import text_to_speech_tool
|
||||||
|
with patch("tools.tts_tool.asyncio.run"):
|
||||||
|
with patch("tools.tts_tool.os.path.exists", return_value=True):
|
||||||
|
with patch("tools.tts_tool.os.path.getsize", return_value=1000):
|
||||||
|
assert "success" in json.loads(text_to_speech_tool("test", speed=0.01))
|
||||||
|
|
||||||
|
@patch("tools.tts_tool._load_tts_config", return_value={})
|
||||||
|
@patch("tools.tts_tool._get_provider", return_value="edge")
|
||||||
|
@patch("tools.tts_tool._import_edge_tts")
|
||||||
|
def test_clamped_high(self, mock_edge, mock_prov, mock_cfg):
|
||||||
|
from tools.tts_tool import text_to_speech_tool
|
||||||
|
with patch("tools.tts_tool.asyncio.run"):
|
||||||
|
with patch("tools.tts_tool.os.path.exists", return_value=True):
|
||||||
|
with patch("tools.tts_tool.os.path.getsize", return_value=1000):
|
||||||
|
assert "success" in json.loads(text_to_speech_tool("test", speed=100.0))
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeTTSRateConversion:
|
||||||
|
def test_rates(self):
|
||||||
|
for speed, expected in [(1.0, "+0%"), (1.5, "+50%"), (0.5, "-50%"), (2.0, "+100%"), (0.25, "-75%")]:
|
||||||
|
pct = int((speed - 1.0) * 100)
|
||||||
|
rate = f"+{pct}%" if pct >= 0 else f"{pct}%"
|
||||||
|
assert rate == expected
|
||||||
@@ -179,8 +179,10 @@ async def _generate_edge_tts(text: str, output_path: str, tts_config: Dict[str,
|
|||||||
_edge_tts = _import_edge_tts()
|
_edge_tts = _import_edge_tts()
|
||||||
edge_config = tts_config.get("edge", {})
|
edge_config = tts_config.get("edge", {})
|
||||||
voice = edge_config.get("voice", DEFAULT_EDGE_VOICE)
|
voice = edge_config.get("voice", DEFAULT_EDGE_VOICE)
|
||||||
|
speed = tts_config.get("_speed_override") or edge_config.get("speed", 1.0)
|
||||||
communicate = _edge_tts.Communicate(text, voice)
|
rate_pct = int((speed - 1.0) * 100)
|
||||||
|
rate_str = f"+{rate_pct}%" if rate_pct >= 0 else f"{rate_pct}%"
|
||||||
|
communicate = _edge_tts.Communicate(text, voice, rate=rate_str)
|
||||||
await communicate.save(output_path)
|
await communicate.save(output_path)
|
||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
@@ -262,11 +264,14 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
|||||||
OpenAIClient = _import_openai_client()
|
OpenAIClient = _import_openai_client()
|
||||||
client = OpenAIClient(api_key=api_key, base_url=base_url)
|
client = OpenAIClient(api_key=api_key, base_url=base_url)
|
||||||
try:
|
try:
|
||||||
|
speed = tts_config.get("_speed_override") or oai_config.get("speed", 1.0)
|
||||||
|
speed = max(0.25, min(4.0, speed))
|
||||||
response = client.audio.speech.create(
|
response = client.audio.speech.create(
|
||||||
model=model,
|
model=model,
|
||||||
voice=voice,
|
voice=voice,
|
||||||
input=text,
|
input=text,
|
||||||
response_format=response_format,
|
response_format=response_format,
|
||||||
|
speed=speed,
|
||||||
extra_headers={"x-idempotency-key": str(uuid.uuid4())},
|
extra_headers={"x-idempotency-key": str(uuid.uuid4())},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -305,7 +310,7 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
|
|||||||
mm_config = tts_config.get("minimax", {})
|
mm_config = tts_config.get("minimax", {})
|
||||||
model = mm_config.get("model", DEFAULT_MINIMAX_MODEL)
|
model = mm_config.get("model", DEFAULT_MINIMAX_MODEL)
|
||||||
voice_id = mm_config.get("voice_id", DEFAULT_MINIMAX_VOICE_ID)
|
voice_id = mm_config.get("voice_id", DEFAULT_MINIMAX_VOICE_ID)
|
||||||
speed = mm_config.get("speed", 1)
|
speed = tts_config.get("_speed_override") or mm_config.get("speed", 1)
|
||||||
vol = mm_config.get("vol", 1)
|
vol = mm_config.get("vol", 1)
|
||||||
pitch = mm_config.get("pitch", 0)
|
pitch = mm_config.get("pitch", 0)
|
||||||
base_url = mm_config.get("base_url", DEFAULT_MINIMAX_BASE_URL)
|
base_url = mm_config.get("base_url", DEFAULT_MINIMAX_BASE_URL)
|
||||||
@@ -447,6 +452,7 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->
|
|||||||
def text_to_speech_tool(
|
def text_to_speech_tool(
|
||||||
text: str,
|
text: str,
|
||||||
output_path: Optional[str] = None,
|
output_path: Optional[str] = None,
|
||||||
|
speed: Optional[float] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Convert text to speech audio.
|
Convert text to speech audio.
|
||||||
@@ -474,6 +480,9 @@ def text_to_speech_tool(
|
|||||||
text = text[:MAX_TEXT_LENGTH]
|
text = text[:MAX_TEXT_LENGTH]
|
||||||
|
|
||||||
tts_config = _load_tts_config()
|
tts_config = _load_tts_config()
|
||||||
|
if speed is not None:
|
||||||
|
speed = max(0.25, min(4.0, speed))
|
||||||
|
tts_config["_speed_override"] = speed
|
||||||
provider = _get_provider(tts_config)
|
provider = _get_provider(tts_config)
|
||||||
|
|
||||||
# Detect platform from gateway env var to choose the best output format.
|
# Detect platform from gateway env var to choose the best output format.
|
||||||
@@ -966,6 +975,10 @@ TTS_SCHEMA = {
|
|||||||
"output_path": {
|
"output_path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional custom file path to save the audio. Defaults to ~/.hermes/audio_cache/<timestamp>.mp3"
|
"description": "Optional custom file path to save the audio. Defaults to ~/.hermes/audio_cache/<timestamp>.mp3"
|
||||||
|
},
|
||||||
|
"speed": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Speech speed multiplier. 1.0 = normal, 0.5 = half speed, 2.0 = double. Range: 0.25-4.0. Edge TTS uses SSML rate, OpenAI uses native speed param, MiniMax passes directly."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["text"]
|
"required": ["text"]
|
||||||
@@ -978,7 +991,8 @@ registry.register(
|
|||||||
schema=TTS_SCHEMA,
|
schema=TTS_SCHEMA,
|
||||||
handler=lambda args, **kw: text_to_speech_tool(
|
handler=lambda args, **kw: text_to_speech_tool(
|
||||||
text=args.get("text", ""),
|
text=args.get("text", ""),
|
||||||
output_path=args.get("output_path")),
|
output_path=args.get("output_path"),
|
||||||
|
speed=args.get("speed")),
|
||||||
check_fn=check_tts_requirements,
|
check_fn=check_tts_requirements,
|
||||||
emoji="🔊",
|
emoji="🔊",
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user