Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions tests/tools/test_tts_markdown_strip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""Tests that text_to_speech_tool strips markdown before dispatching to provider.

Regression for the bug where the agent-callable TTS entry point passed raw
markdown (e.g. **bold**, # headers, `code`) to providers like Edge TTS, which
then verbalized the markdown artifacts ("double-asterisk Bold double-asterisk").

Two other TTS call sites (gateway/run.py:_send_voice_reply and
gateway/platforms/base.py Auto-TTS path) already strip markdown; this aligns
the third call site (text_to_speech_tool) with them.
"""

import json
from unittest.mock import patch

import pytest

from tools.tts_tool import text_to_speech_tool


class TestTextToSpeechToolMarkdownStrip:
"""text_to_speech_tool() must strip markdown before provider dispatch."""

def test_strips_bold_before_provider_call(self, tmp_path):
"""**bold** must reach the provider as plain 'bold'."""
captured = {}

def fake_edge(text, output_path, tts_config):
captured["text"] = text
from pathlib import Path
Path(output_path).write_bytes(b"fake-mp3")
return output_path

with patch("tools.tts_tool._generate_edge_tts", side_effect=fake_edge), \
patch("tools.tts_tool._import_edge_tts", return_value=None), \
patch("tools.tts_tool._load_tts_config", return_value={"provider": "edge"}):
text_to_speech_tool(
text="This is **bold** text",
output_path=str(tmp_path / "out.mp3"),
)

assert "**" not in captured["text"]
assert "bold" in captured["text"]

def test_strips_headers_before_provider_call(self, tmp_path):
"""# Header must reach the provider without leading hashes."""
captured = {}

def fake_edge(text, output_path, tts_config):
captured["text"] = text
from pathlib import Path
Path(output_path).write_bytes(b"fake-mp3")
return output_path

with patch("tools.tts_tool._generate_edge_tts", side_effect=fake_edge), \
patch("tools.tts_tool._import_edge_tts", return_value=None), \
patch("tools.tts_tool._load_tts_config", return_value={"provider": "edge"}):
text_to_speech_tool(
text="## Summary\nSome text",
output_path=str(tmp_path / "out.mp3"),
)

assert "##" not in captured["text"]
assert "Summary" in captured["text"]

def test_strips_inline_code_before_provider_call(self, tmp_path):
"""Backtick-wrapped inline code must lose its backticks."""
captured = {}

def fake_edge(text, output_path, tts_config):
captured["text"] = text
from pathlib import Path
Path(output_path).write_bytes(b"fake-mp3")
return output_path

with patch("tools.tts_tool._generate_edge_tts", side_effect=fake_edge), \
patch("tools.tts_tool._import_edge_tts", return_value=None), \
patch("tools.tts_tool._load_tts_config", return_value={"provider": "edge"}):
text_to_speech_tool(
text="Run `pip install foo` to install",
output_path=str(tmp_path / "out.mp3"),
)

assert "`" not in captured["text"]
assert "pip install foo" in captured["text"]

def test_strips_list_markers_before_provider_call(self, tmp_path):
"""- and * list markers should not be spoken."""
captured = {}

def fake_edge(text, output_path, tts_config):
captured["text"] = text
from pathlib import Path
Path(output_path).write_bytes(b"fake-mp3")
return output_path

with patch("tools.tts_tool._generate_edge_tts", side_effect=fake_edge), \
patch("tools.tts_tool._import_edge_tts", return_value=None), \
patch("tools.tts_tool._load_tts_config", return_value={"provider": "edge"}):
text_to_speech_tool(
text="- item one\n- item two",
output_path=str(tmp_path / "out.mp3"),
)

assert "- " not in captured["text"]
assert "item one" in captured["text"]
assert "item two" in captured["text"]

def test_truncation_uses_stripped_length(self, tmp_path):
"""Provider max_len budget must apply to spoken length, not raw markdown."""
# 50 chars of '*' bracketing 50 chars of plain text = 100 raw, ~50 spoken.
# If max_len is 80 and we don't strip first, truncation cuts spoken text.
# After strip-first, all 50 spoken chars survive.
raw = "*" * 25 + "Hello clean world. This is the actual content." + "*" * 25
captured = {}

def fake_edge(text, output_path, tts_config):
captured["text"] = text
from pathlib import Path
Path(output_path).write_bytes(b"fake-mp3")
return output_path

with patch("tools.tts_tool._generate_edge_tts", side_effect=fake_edge), \
patch("tools.tts_tool._import_edge_tts", return_value=None), \
patch("tools.tts_tool._load_tts_config", return_value={"provider": "edge"}), \
patch("tools.tts_tool._resolve_max_text_length", return_value=80):
text_to_speech_tool(
text=raw,
output_path=str(tmp_path / "out.mp3"),
)

# The full plain content should survive (50 chars < 80 budget) once stripped.
assert "Hello clean world" in captured["text"]
assert "actual content" in captured["text"]

def test_command_provider_skip_markdown_strip_opt_out(self, tmp_path):
"""A command provider with skip_markdown_strip:true gets raw text."""
captured = {}

def fake_command(text, output_path, provider_name, config, tts_config):
captured["text"] = text
from pathlib import Path
Path(output_path).write_bytes(b"fake-wav")
return output_path

tts_config = {
"provider": "ssml-piper",
"providers": {
"ssml-piper": {
"type": "command",
"command": "echo {input_path} > {output_path}",
"output_format": "wav",
"skip_markdown_strip": True,
},
},
}

with patch("tools.tts_tool._generate_command_tts", side_effect=fake_command), \
patch("tools.tts_tool._load_tts_config", return_value=tts_config):
text_to_speech_tool(
text="<break time=\"500ms\"/>**Important**",
output_path=str(tmp_path / "out.wav"),
)

# SSML and markdown both pass through untouched.
assert "<break" in captured["text"]
assert "**Important**" in captured["text"]

def test_command_provider_default_strips_markdown(self, tmp_path):
"""Without skip_markdown_strip, command provider also gets stripped text."""
captured = {}

def fake_command(text, output_path, provider_name, config, tts_config):
captured["text"] = text
from pathlib import Path
Path(output_path).write_bytes(b"fake-wav")
return output_path

tts_config = {
"provider": "voxcpm",
"providers": {
"voxcpm": {
"type": "command",
"command": "voxcpm --in {input_path} --out {output_path}",
"output_format": "wav",
},
},
}

with patch("tools.tts_tool._generate_command_tts", side_effect=fake_command), \
patch("tools.tts_tool._load_tts_config", return_value=tts_config):
text_to_speech_tool(
text="This is **bold** text",
output_path=str(tmp_path / "out.wav"),
)

assert "**" not in captured["text"]
assert "bold" in captured["text"]
26 changes: 20 additions & 6 deletions tools/tts_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,15 +426,29 @@ def text_to_speech_tool(
separate valid files and no over-limit artifact is ever returned."""
if not text or not text.strip():
return tool_error("Text is required", success=False)
try: # shared cleaner: markdown, emoji, think blocks, verifier footer, units, newlines
from tools.tts_text_normalize import prepare_spoken_text
text = prepare_spoken_text(text, max_chars=None)
except Exception:
tts_config, provider = _apply_call_overrides(_load_tts_config(), speed, provider)
command_provider_config = _resolve_command_provider_config(provider, tts_config)
# Resolve the command-provider config BEFORE normalizing, so an SSML-aware
# command CLI that sets ``skip_markdown_strip`` receives the raw markup
# untouched. Otherwise prepare_spoken_text (markdown/SSML rewrite) would
# mangle the text before the opt-out is even consulted.
skip_strip = bool(
command_provider_config
and command_provider_config.get("skip_markdown_strip")
)
# shared cleaner: markdown, emoji, think blocks, verifier footer, units,
# newlines. Skipped for opted-out command providers, which want the raw
# markup preserved.
if not skip_strip:
try:
from tools.tts_text_normalize import prepare_spoken_text
text = prepare_spoken_text(text, max_chars=None)
except Exception:
text = text.strip()
else:
text = text.strip()
if not text:
return tool_error("Text is empty after TTS cleanup", success=False)
tts_config, provider = _apply_call_overrides(_load_tts_config(), speed, provider)
command_provider_config = _resolve_command_provider_config(provider, tts_config)
max_len = _resolve_max_text_length(provider, tts_config)
chunks = _split_text_for_tts(text, max_len)
if not chunks:
Expand Down
3 changes: 2 additions & 1 deletion website/docs/user-guide/features/tts.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ tts:

Local engines (Piper, KittenTTS) load their model lazily, so without help the *first* spoken reply after you turn speech on pays the whole model load — and on a fresh install the voice download — as silence before the first word. Hermes treats the speech-output toggles as the signal that TTS is about to be needed:

- **Desktop** — **Read replies aloud** is a desktop-local preference, independent of the gateway's `voice.auto_tts` setting in Settings → Voice. It migrates the shared value once, then later gateway configuration changes do not override the desktop toggle. If local storage is full or unavailable, the choice still lasts for this window; persistence across a reload remains best-effort. Turning on **Read replies aloud**, or starting a **voice conversation**, pre-loads the configured engine in the background right away. Turning both off again unloads the resident model (a Piper voice is tens of MB; KittenTTS up to ~80MB) so it isn't parked in RAM for nothing.
- **Desktop** — turning on **Read replies aloud**, or starting a **voice conversation**, pre-loads the configured engine in the background right away. Turning both off again unloads the resident model (a Piper voice is tens of MB; KittenTTS up to ~80MB) so it isn't parked in RAM for nothing.
- **CLI / TUI** — `/voice tts` (and `/voice on` when `voice.auto_tts` is set) do the same; `/voice off` releases.

Each toggle holds a *lease* on the engine; the model is only unloaded when the last lease across surfaces is released, so switching off read-aloud in one Desktop window never pulls the voice out from under a conversation running in another. For cloud providers there is no model to hold — the toggle only makes sure a lazily-installed SDK (edge-tts, ElevenLabs, Mistral) is present. Warm-up is best-effort: if the engine can't load, the toggle still succeeds and the first reply falls back to loading on demand as before.
Expand Down Expand Up @@ -368,6 +368,7 @@ Use `{{` and `}}` for literal braces.
| `output_format` | `mp3` | One of `mp3` / `wav` / `ogg` / `flac`. Auto-inferred from the output extension if Hermes picks a path. |
| `voice_compatible` | `false` | When `true`, Hermes converts MP3/WAV output to Opus/OGG via ffmpeg so Telegram renders a voice bubble. |
| `max_text_length` | `5000` | Maximum input characters per command invocation; longer text is split into ordered chunks. |
| `skip_markdown_strip` | `false` | When `true`, the input text is passed to the command verbatim (Markdown left intact). For providers that consume raw markup themselves, e.g. SSML or Markdown-aware engines; with the default `false`, Markdown formatting is stripped so it isn't read aloud. |
| `voice` / `model` | empty | Passed to the command as placeholder values only. |
| `warm_command` / `release_command` | unset | Shell commands run when a surface toggles speech output on / when the last lease across surfaces is released — e.g. `curl -s localhost:5002/load?model={model}` to preload a local TTS server, and its `unload` counterpart. Best-effort and non-blocking: run in the background with the same `timeout`, `env_passthrough` and `{voice}` / `{model}` / `{speed}` placeholders as `command`; output is discarded and failures are only logged at debug. |

Expand Down