feat(tts): expose speed parameter in text_to_speech tool

Add optional 'speed' parameter (0.25-4.0) to the text_to_speech tool
schema and handler. When provided by the model, it overrides the
config-level tts.speed setting, enabling per-request speed control
without config changes.

Use cases:
- Language learning: slow playback (0.5x) for pronunciation practice
- Accessibility: adjustable speed for hearing preferences
- Content review: accelerated playback for long text

The speed value is clamped to [0.25, 4.0] and injected into tts_config
before dispatching to any provider (Edge, OpenAI, MiniMax, etc.), so
all existing provider-level speed handling works transparently.

Includes 3 new tests for tool-level speed injection, clamping, and
config preservation when speed is not specified.
This commit is contained in:
Hu 2026-06-18 16:42:02 +08:00 committed by Teknium
parent d9336e7453
commit 8171e8ebb3
2 changed files with 82 additions and 1 deletions

View File

@ -238,3 +238,70 @@ class TestMinimaxTtsLegacyTextToSpeech:
_, output = self._run({}, tmp_path, monkeypatch)
with open(output, "rb") as f:
assert f.read() == b"\x00\x01\x02\x03"
# ---------------------------------------------------------------------------
# Tool-level speed parameter (text_to_speech_tool speed injection)
# ---------------------------------------------------------------------------
class TestToolLevelSpeed:
"""Verify that the speed parameter on text_to_speech_tool injects into config."""
def test_speed_injected_into_config(self, tmp_path, monkeypatch):
"""When speed is passed to the tool, it overrides config speed."""
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
mock_response = MagicMock()
mock_client = MagicMock()
mock_client.audio.speech.create.return_value = mock_response
mock_cls = MagicMock(return_value=mock_client)
with patch("tools.tts_tool._import_openai_client", return_value=mock_cls), \
patch("tools.tts_tool._resolve_openai_audio_client_config",
return_value=("test-key", None)), \
patch("tools.tts_tool._load_tts_config", return_value={"provider": "openai", "openai": {}}), \
patch("tools.tts_tool._get_provider", return_value="openai"), \
patch("tools.tts_tool._resolve_command_provider_config", return_value=None), \
patch("tools.tts_tool._resolve_max_text_length", return_value=4096), \
patch("tools.tts_tool._generate_openai_tts") as mock_gen, \
patch("gateway.session_context.get_session_env", return_value=""):
from tools.tts_tool import text_to_speech_tool
text_to_speech_tool("Hello", str(tmp_path / "out.mp3"), speed=0.7)
# Verify the tts_config passed to the generator has speed=0.7
call_args = mock_gen.call_args
config_passed = call_args[0][2] # (text, output_path, tts_config)
assert config_passed["speed"] == 0.7
def test_speed_clamped_range(self, tmp_path, monkeypatch):
"""Speed values outside 0.25-4.0 are clamped."""
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "openai", "openai": {}}), \
patch("tools.tts_tool._get_provider", return_value="openai"), \
patch("tools.tts_tool._resolve_command_provider_config", return_value=None), \
patch("tools.tts_tool._resolve_max_text_length", return_value=4096), \
patch("tools.tts_tool._generate_openai_tts") as mock_gen, \
patch("gateway.session_context.get_session_env", return_value=""):
from tools.tts_tool import text_to_speech_tool
text_to_speech_tool("Hello", str(tmp_path / "out.mp3"), speed=10.0)
config_passed = mock_gen.call_args[0][2]
assert config_passed["speed"] == 4.0
def test_no_speed_preserves_config(self, tmp_path, monkeypatch):
"""When speed is None, config is not mutated."""
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
original_config = {"provider": "openai", "openai": {}, "speed": 1.5}
with patch("tools.tts_tool._load_tts_config", return_value=original_config), \
patch("tools.tts_tool._get_provider", return_value="openai"), \
patch("tools.tts_tool._resolve_command_provider_config", return_value=None), \
patch("tools.tts_tool._resolve_max_text_length", return_value=4096), \
patch("tools.tts_tool._generate_openai_tts") as mock_gen, \
patch("gateway.session_context.get_session_env", return_value=""):
from tools.tts_tool import text_to_speech_tool
text_to_speech_tool("Hello", str(tmp_path / "out.mp3"), speed=None)
config_passed = mock_gen.call_args[0][2]
assert config_passed.get("speed") == 1.5 # original config preserved
assert original_config.get("speed") == 1.5 # original not mutated

View File

@ -2567,6 +2567,7 @@ def _generate_kittentts(text: str, output_path: str, tts_config: Dict[str, Any])
def text_to_speech_tool(
text: str,
output_path: Optional[str] = None,
speed: Optional[float] = None,
) -> str:
"""
Convert text to speech audio.
@ -2581,6 +2582,7 @@ def text_to_speech_tool(
Args:
text: The text to convert to speech.
output_path: Optional custom save path. Defaults to ~/voice-memos/<timestamp>.mp3
speed: Optional playback speed multiplier (0.25-4.0). Overrides config.yaml.
Returns:
str: JSON result with success, file_path, and optionally MEDIA tag.
@ -2597,6 +2599,13 @@ def text_to_speech_tool(
return tool_error("Text is empty after TTS cleanup", success=False)
tts_config = _load_tts_config()
# When the model supplies a speed parameter, inject it into the config
# so all downstream provider functions pick it up uniformly.
if speed is not None:
clamped = max(0.25, min(4.0, float(speed)))
tts_config = dict(tts_config) # shallow copy to avoid mutating the cache
tts_config["speed"] = clamped
provider = _get_provider(tts_config)
# User-declared command provider (type: command under tts.providers.<name>)
@ -3320,6 +3329,10 @@ TTS_SCHEMA = {
"output_path": {
"type": "string",
"description": f"Optional custom file path to save the audio. Defaults to {display_hermes_home()}/audio_cache/<timestamp>.mp3"
},
"speed": {
"type": "number",
"description": "Playback speed multiplier. 1.0 = normal, 0.5 = very slow (language learning), 2.0 = fast. Range: 0.25-4.0. Overrides the speed configured in config.yaml."
}
},
"required": ["text"]
@ -3332,7 +3345,8 @@ registry.register(
schema=TTS_SCHEMA,
handler=lambda args, **kw: text_to_speech_tool(
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,
emoji="🔊",
)