mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Consolidates all TTS text-preparation paths onto tools/tts_text_normalize.prepare_spoken_text: - strip_nonspoken_blocks: removes <think> reasoning blocks (#34213, incl. unterminated streaming blocks) and the end-of-turn file-mutation verifier footer emitted by run_agent.py (#40772). - flatten_newlines_for_payload: collapses newlines into sentence breaks so newline-sensitive OpenAI-compatible providers (Kokoro) speak the whole script instead of truncating at the first newline (#9004). - tools/tts_tool._strip_markdown_for_tts (voice-mode streaming + web dashboard path) now delegates to the shared cleaner, with the legacy regex pipeline kept as a best-effort fallback. - hermes_cli/voice.py speak_text and cli.py _voice_speak_response now use the shared cleaner instead of their own duplicated regex pipelines. - gateway auto-TTS fallback also strips think blocks. Tests: tests/tools/test_tts_prepare_spoken.py covers think blocks, verifier footer, emoji, newline flattening, and the shared-cleaner wiring on the tool/streaming/gateway paths. Updated the header expectation in test_voice_cli_integration.py for the heading-fold behavior of the shared cleaner. Closes #34213, #9004, #40772
359 lines
16 KiB
Python
359 lines
16 KiB
Python
"""Tests for TTS speed configuration across providers."""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_env(monkeypatch):
|
|
for key in (
|
|
"OPENAI_API_KEY",
|
|
"MINIMAX_API_KEY",
|
|
"MINIMAX_GROUP_ID",
|
|
"HERMES_SESSION_PLATFORM",
|
|
):
|
|
monkeypatch.delenv(key, raising=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Edge TTS speed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestEdgeTtsSpeed:
|
|
def _run(self, tts_config, tmp_path):
|
|
mock_comm = MagicMock()
|
|
mock_comm.save = AsyncMock()
|
|
mock_edge = MagicMock()
|
|
mock_edge.Communicate = MagicMock(return_value=mock_comm)
|
|
|
|
with patch("tools.tts_tool._import_edge_tts", return_value=mock_edge):
|
|
from tools.tts_tool import _generate_edge_tts
|
|
asyncio.run(_generate_edge_tts("Hello", str(tmp_path / "out.mp3"), tts_config))
|
|
return mock_edge.Communicate
|
|
|
|
def test_default_no_rate_kwarg(self, tmp_path):
|
|
"""No speed config => no rate kwarg passed to Communicate."""
|
|
comm_cls = self._run({}, tmp_path)
|
|
kwargs = comm_cls.call_args[1]
|
|
assert "rate" not in kwargs
|
|
|
|
def test_global_speed_applied(self, tmp_path):
|
|
"""Global tts.speed used as fallback."""
|
|
comm_cls = self._run({"speed": 1.5}, tmp_path)
|
|
kwargs = comm_cls.call_args[1]
|
|
assert kwargs["rate"] == "+50%"
|
|
|
|
def test_provider_speed_overrides_global(self, tmp_path):
|
|
"""tts.edge.speed takes precedence over tts.speed."""
|
|
comm_cls = self._run({"speed": 1.5, "edge": {"speed": 2.0}}, tmp_path)
|
|
kwargs = comm_cls.call_args[1]
|
|
assert kwargs["rate"] == "+100%"
|
|
|
|
def test_speed_below_one(self, tmp_path):
|
|
"""Speed < 1.0 produces a negative rate string."""
|
|
comm_cls = self._run({"speed": 0.5}, tmp_path)
|
|
kwargs = comm_cls.call_args[1]
|
|
assert kwargs["rate"] == "-50%"
|
|
|
|
def test_speed_exactly_one_no_rate(self, tmp_path):
|
|
"""Explicit speed=1.0 should not pass rate kwarg."""
|
|
comm_cls = self._run({"speed": 1.0}, tmp_path)
|
|
kwargs = comm_cls.call_args[1]
|
|
assert "rate" not in kwargs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OpenAI TTS speed
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestOpenaiTtsSpeed:
|
|
def _run(self, tts_config, tmp_path, monkeypatch):
|
|
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, False)):
|
|
from tools.tts_tool import _generate_openai_tts
|
|
_generate_openai_tts("Hello", str(tmp_path / "out.mp3"), tts_config)
|
|
return mock_client.audio.speech.create
|
|
|
|
def test_default_no_speed_kwarg(self, tmp_path, monkeypatch):
|
|
"""No speed config => no speed kwarg in create call."""
|
|
create = self._run({}, tmp_path, monkeypatch)
|
|
kwargs = create.call_args[1]
|
|
assert "speed" not in kwargs
|
|
|
|
def test_global_speed_applied(self, tmp_path, monkeypatch):
|
|
"""Global tts.speed used as fallback."""
|
|
create = self._run({"speed": 1.5}, tmp_path, monkeypatch)
|
|
kwargs = create.call_args[1]
|
|
assert kwargs["speed"] == 1.5
|
|
|
|
def test_provider_speed_overrides_global(self, tmp_path, monkeypatch):
|
|
"""tts.openai.speed takes precedence over tts.speed."""
|
|
create = self._run({"speed": 1.5, "openai": {"speed": 2.0}}, tmp_path, monkeypatch)
|
|
kwargs = create.call_args[1]
|
|
assert kwargs["speed"] == 2.0
|
|
|
|
def test_speed_clamped_low(self, tmp_path, monkeypatch):
|
|
"""Speed below 0.25 is clamped to 0.25."""
|
|
create = self._run({"speed": 0.1}, tmp_path, monkeypatch)
|
|
kwargs = create.call_args[1]
|
|
assert kwargs["speed"] == 0.25
|
|
|
|
def test_speed_clamped_high(self, tmp_path, monkeypatch):
|
|
"""Speed above 4.0 is clamped to 4.0."""
|
|
create = self._run({"speed": 10.0}, tmp_path, monkeypatch)
|
|
kwargs = create.call_args[1]
|
|
assert kwargs["speed"] == 4.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OpenAI TTS language (lang_code for OpenAI-compatible endpoints)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestOpenaiTtsLangCode:
|
|
def _run(self, tts_config, tmp_path, monkeypatch):
|
|
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, False)):
|
|
from tools.tts_tool import _generate_openai_tts
|
|
_generate_openai_tts("Hola", str(tmp_path / "out.mp3"), tts_config)
|
|
return mock_client.audio.speech.create
|
|
|
|
def test_default_no_extra_body(self, tmp_path, monkeypatch):
|
|
"""No language config => no extra_body kwarg in create call."""
|
|
create = self._run({}, tmp_path, monkeypatch)
|
|
kwargs = create.call_args[1]
|
|
assert "extra_body" not in kwargs
|
|
|
|
def test_language_forwarded_as_lang_code(self, tmp_path, monkeypatch):
|
|
"""tts.openai.language is forwarded as extra_body lang_code."""
|
|
create = self._run({"openai": {"language": "es"}}, tmp_path, monkeypatch)
|
|
kwargs = create.call_args[1]
|
|
assert kwargs["extra_body"] == {"lang_code": "es"}
|
|
|
|
def test_empty_language_omitted(self, tmp_path, monkeypatch):
|
|
"""Empty language string => extra_body omitted."""
|
|
create = self._run({"openai": {"language": ""}}, tmp_path, monkeypatch)
|
|
kwargs = create.call_args[1]
|
|
assert "extra_body" not in kwargs
|
|
|
|
def test_global_language_not_forwarded(self, tmp_path, monkeypatch):
|
|
"""Only tts.openai.language is honored, not a top-level tts.language."""
|
|
create = self._run({"language": "es"}, tmp_path, monkeypatch)
|
|
kwargs = create.call_args[1]
|
|
assert "extra_body" not in kwargs
|
|
|
|
def test_language_coexists_with_speed(self, tmp_path, monkeypatch):
|
|
"""language and speed are forwarded independently."""
|
|
create = self._run({"openai": {"language": "es", "speed": 2.0}},
|
|
tmp_path, monkeypatch)
|
|
kwargs = create.call_args[1]
|
|
assert kwargs["extra_body"] == {"lang_code": "es"}
|
|
assert kwargs["speed"] == 2.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MiniMax TTS (t2a_v2 endpoint: nested voice_setting/audio_setting,
|
|
# JSON response with hex-encoded audio. Falls back to the legacy
|
|
# text_to_speech endpoint shape when the base_url points at it.)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _hex_response(payload_audio: bytes = b"\x00\x01\x02\x03"):
|
|
"""Build a mock response shaped like a successful t2a_v2 reply."""
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {"Content-Type": "application/json"}
|
|
mock_response.json.return_value = {
|
|
"data": {"audio": payload_audio.hex(), "status": 2},
|
|
"base_resp": {"status_code": 0, "status_msg": "success"},
|
|
}
|
|
return mock_response
|
|
|
|
|
|
class TestMinimaxTtsT2aV2:
|
|
"""Default path: base_url contains 't2a_v2'."""
|
|
|
|
def _run(self, tts_config, tmp_path, monkeypatch, response=None):
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "test-key")
|
|
resp = response if response is not None else _hex_response()
|
|
with patch("requests.post", return_value=resp) as mock_post:
|
|
from tools.tts_tool import _generate_minimax_tts
|
|
output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config)
|
|
return mock_post, output
|
|
|
|
def test_nested_payload(self, tmp_path, monkeypatch):
|
|
"""Default endpoint uses nested voice_setting / audio_setting."""
|
|
mock_post, _ = self._run({}, tmp_path, monkeypatch)
|
|
payload = mock_post.call_args[1]["json"]
|
|
assert payload["model"] == "speech-02-hd"
|
|
assert payload["text"] == "Hello"
|
|
assert "voice_setting" in payload
|
|
assert payload["voice_setting"]["voice_id"] == "English_expressive_narrator"
|
|
assert "audio_setting" in payload
|
|
assert payload["audio_setting"]["format"] == "mp3"
|
|
# Don't send flat top-level voice_id alongside nested voice_setting.
|
|
assert "voice_id" not in payload
|
|
|
|
def test_decodes_hex_audio(self, tmp_path, monkeypatch):
|
|
"""t2a_v2 hex-encoded audio is decoded and written verbatim."""
|
|
_, output = self._run({}, tmp_path, monkeypatch)
|
|
with open(output, "rb") as f:
|
|
assert f.read() == b"\x00\x01\x02\x03"
|
|
|
|
def test_default_url_is_t2a_v2(self, tmp_path, monkeypatch):
|
|
"""Default base URL points at the live t2a_v2 endpoint."""
|
|
mock_post, _ = self._run({}, tmp_path, monkeypatch)
|
|
url = mock_post.call_args[0][0]
|
|
assert "t2a_v2" in url
|
|
assert "api.minimax.io" in url
|
|
|
|
def test_group_id_from_config(self, tmp_path, monkeypatch):
|
|
"""group_id from config attaches as ?GroupId=<id>."""
|
|
mock_post, _ = self._run({"minimax": {"group_id": "G123"}}, tmp_path, monkeypatch)
|
|
url = mock_post.call_args[0][0]
|
|
assert "GroupId=G123" in url
|
|
|
|
def test_group_id_from_env(self, tmp_path, monkeypatch):
|
|
"""MINIMAX_GROUP_ID env var attaches as ?GroupId=<id>."""
|
|
monkeypatch.setenv("MINIMAX_GROUP_ID", "G456")
|
|
mock_post, _ = self._run({}, tmp_path, monkeypatch)
|
|
url = mock_post.call_args[0][0]
|
|
assert "GroupId=G456" in url
|
|
|
|
def test_group_id_already_in_url_left_alone(self, tmp_path, monkeypatch):
|
|
"""If user already set GroupId in base_url, don't double-append it."""
|
|
cfg = {"minimax": {
|
|
"base_url": "https://api.minimax.io/v1/t2a_v2?GroupId=PRESET",
|
|
"group_id": "IGNORED",
|
|
}}
|
|
mock_post, _ = self._run(cfg, tmp_path, monkeypatch)
|
|
url = mock_post.call_args[0][0]
|
|
assert url.count("GroupId=") == 1
|
|
assert "GroupId=PRESET" in url
|
|
|
|
def test_api_error_raises(self, tmp_path, monkeypatch):
|
|
"""Non-zero base_resp.status_code surfaces as RuntimeError."""
|
|
resp = MagicMock()
|
|
resp.status_code = 200
|
|
resp.headers = {"Content-Type": "application/json"}
|
|
resp.json.return_value = {
|
|
"data": {"audio": "", "status": 1},
|
|
"base_resp": {"status_code": 2013, "status_msg": "invalid voice"},
|
|
}
|
|
with pytest.raises(RuntimeError, match="2013"):
|
|
self._run({}, tmp_path, monkeypatch, response=resp)
|
|
|
|
|
|
class TestMinimaxTtsLegacyTextToSpeech:
|
|
"""Legacy path: caller pins base_url to the old text_to_speech endpoint."""
|
|
|
|
LEGACY_URL = "https://api.minimax.chat/v1/text_to_speech"
|
|
|
|
def _run(self, tts_config, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("MINIMAX_API_KEY", "test-key")
|
|
cfg = dict(tts_config)
|
|
cfg.setdefault("minimax", {})["base_url"] = self.LEGACY_URL
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.headers = {"Content-Type": "audio/mpeg"}
|
|
mock_response.content = b"\x00\x01\x02\x03"
|
|
with patch("requests.post", return_value=mock_response) as mock_post:
|
|
from tools.tts_tool import _generate_minimax_tts
|
|
output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), cfg)
|
|
return mock_post, output
|
|
|
|
def test_flat_payload(self, tmp_path, monkeypatch):
|
|
"""Legacy endpoint keeps the flat {model, text, voice_id} shape."""
|
|
mock_post, _ = self._run({}, tmp_path, monkeypatch)
|
|
payload = mock_post.call_args[1]["json"]
|
|
assert "voice_id" in payload
|
|
assert "voice_setting" not in payload
|
|
assert "audio_setting" not in payload
|
|
|
|
def test_writes_raw_audio(self, tmp_path, monkeypatch):
|
|
"""Legacy endpoint returns raw bytes written directly to file."""
|
|
_, 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, False)), \
|
|
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
|