hermes-agent/tests/tools/test_tts_text_normalize.py
Alexander Russell a9a9005f31 feat(tts): normalize spoken text (units, symbols, markdown) before synthesis
Auto-TTS previously fed raw chat Markdown and compact symbols straight to the
speech provider, so units were read as stray letters and headings or bullets ran
together. This routes spoken text through a new normalizer,
tools/tts_text_normalize.prepare_spoken_text, that expands units (for example a
temperature written with the degree symbol becomes "degrees Celsius") and
flattens Markdown into a transcript-like script with sentence pauses.

The normalizer is best-effort: if it ever fails the code falls back to the
previous markdown-strip behavior, so auto-TTS keeps working. The Telegram voice
caption uses the same normalized text. Includes a unit test.
2026-07-28 11:55:01 -07:00

70 lines
2.7 KiB
Python

from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter
from tools.tts_text_normalize import prepare_spoken_text
class _DummyAdapter(BasePlatformAdapter):
def __init__(self):
super().__init__(PlatformConfig(enabled=True, token="test"), Platform.TELEGRAM)
async def connect(self):
return True
async def disconnect(self):
pass
async def send(self, chat_id, content, **kwargs):
raise AssertionError("not used")
async def get_chat_info(self, chat_id):
return {"id": chat_id, "type": "dm"}
def test_prepare_spoken_text_expands_celsius_and_weather_units():
raw = """## Christchurch today\n\n- **Now:** about **14°C**, feels like **14°C**\n- **Wind:** 9 km/h\n- **Rain:** 1.3 mm\n- **Range:** 11\u201317°C\n"""
spoken = prepare_spoken_text(raw)
assert "##" not in spoken
assert "**" not in spoken
assert "14 degrees Celsius" in spoken
assert "11 to 17 degrees Celsius" in spoken
assert "9 kilometres per hour" in spoken
assert "1.3 millimetres" in spoken
assert "°C" not in spoken
assert "km/h" not in spoken
def test_prepare_spoken_text_flattens_visual_formatting_for_tts():
raw = """## Short answer\n\n- [link text](https://example.com) → NZ$120 & 80% likely\n- `inline code` should not keep backticks\n"""
spoken = prepare_spoken_text(raw)
assert "Short answer, link text to 120 New Zealand dollars and 80 percent likely" in spoken
assert "inline code should not keep backticks" in spoken
assert "https://" not in spoken
assert "`" not in spoken
assert "" not in spoken
assert "&" not in spoken
def test_gateway_auto_tts_preparation_uses_spoken_normalizer():
adapter = _DummyAdapter()
spoken = adapter.prepare_tts_text("## Weather\n- Now: 14°C, wind 9 km/h")
assert spoken == "Weather, Now: 14 degrees Celsius, wind 9 kilometres per hour."
def test_prepare_spoken_text_polish_edge_cases():
# Heading folds into the next sentence as a lead-in, not a bare label.
assert prepare_spoken_text("## Weather\nIt will be sunny") == "Weather, It will be sunny."
# Bare degree unit (no leading number) still expands.
assert "degrees Celsius" in prepare_spoken_text("measured in °C")
# Trailing comma is not swallowed into the amount.
assert "300 US dollars" in prepare_spoken_text("US$300, next")
# Real numeric rates expand, but and/or, N/A, IDs and dates are left intact.
assert "5 dollars per month" in prepare_spoken_text("$5/month")
assert "and/or" in prepare_spoken_text("choose and/or option")
assert "N/A" in prepare_spoken_text("status N/A here")
assert "2026/06/02" in prepare_spoken_text("due 2026/06/02 ok")