From 8171e8ebb38e177f8f57835746430af58ec54194 Mon Sep 17 00:00:00 2001 From: Hu Date: Thu, 18 Jun 2026 16:42:02 +0800 Subject: [PATCH] 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. --- tests/tools/test_tts_speed.py | 67 +++++++++++++++++++++++++++++++++++ tools/tts_tool.py | 16 ++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_tts_speed.py b/tests/tools/test_tts_speed.py index d079418e78d9d..1c9ee5a173145 100644 --- a/tests/tools/test_tts_speed.py +++ b/tests/tools/test_tts_speed.py @@ -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 diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 9a691607c5ebe..ffb00a08c5824 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -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/.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.) @@ -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/.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="🔊", )