feat(tts): add text preprocessing for natural speech output - #8205
feat(tts): add text preprocessing for natural speech output#8205kas-cor wants to merge 7 commits into
Conversation
Add _preprocess_tts_text() function that normalizes text before TTS conversion to prevent awkward readouts: - Strip markdown: code blocks, bold/italic, inline code, links, headings - Replace common emojis with spoken equivalents (e.g. ✅ → 'done', ⬆️ → 'update') — localized to user's language - Strip remaining Unicode emojis (10 major ranges covered) - Convert box-drawing chars and table pipes to pauses/spaces - Remove MEDIA: tags - Collapse excessive whitespace and newlines Controlled by tts.preprocess config option (default: true). Set tts.preprocess: false in config.yaml to disable. Called automatically in text_to_speech_tool() before passing text to any TTS provider. Works for all providers (Edge, ElevenLabs, OpenAI, MiniMax, Mistral, NeuTTS).
…onfig toggle
- _EMOJI_SPEECH_MAP: each emoji maps to {lang: spoken_text} for 13 languages
(en, ru, de, es, fr, pt, ja, zh, ko, it, pl, uk, tr)
- _detect_lang_from_voice(): extracts lang from TTS voice name (e.g.
'ru-RU-SvetlanaNeural' → 'ru')
- _lookup_emoji_speech(): falls back lang → prefix → en → silent
- _preprocess_tts_text() now accepts voice param for lang detection
- text_to_speech_tool() passes configured voice to preprocess
- tts.preprocess config option (default: true) to disable preprocessing
Language for TTS preprocessing resolved via: 1. tts.language in config (explicit override) 2. stt.local.language in config (reuse STT setting) 3. Voice name extraction (Edge TTS) 4. 'en' as fallback Fixed ElevenLabs/OpenAI voice IDs without language info. Changed _preprocess_tts_text() signature: voice='' → lang='en'.
- cli-config.yaml.example: add full TTS section with all providers, preprocess toggle, language override, and voice examples - configuration.md: add 'TTS Text Preprocessing' subsection explaining preprocess feature, language resolution, and supported languages
Replace duplicated markdown-stripping regex logic in _preprocess_tts_text with a call to the existing _strip_markdown_for_tts function. This eliminates 12 lines of redundant regex operations (code blocks, bold, italic, links, headings, HR, etc.) that were duplicating what _strip_markdown_for_tts already does. Steps 1-6 (markdown stripping) are now a single call. Steps 7-12 renumbered to 2-7 for clarity. No behavior change — _strip_markdown_for_tts produces equivalent output for all markdown patterns that _preprocess_tts_text was handling.
- Add _preprocess_tts_text() that strips emojis, table/box-drawing chars, MEDIA: tags, and optionally converts newlines to sentence breaks - Newline stripping is configurable via tts.strip_newlines (default: true) because some TTS backends like Kokoro silently truncate at the first \n - Inject voice-mode system prompt when input is MessageType.VOICE: instructs LLM to be concise, avoid emojis, and note that response will be converted to live speech - Update both TTS paths (runner _send_voice_reply + base adapter auto-TTS) to use _preprocess_tts_text instead of raw _strip_markdown_for_tts - Add 9 unit tests for the new preprocessing function Evidence: Kokoro TTS API produces identical 205KB audio for single-line vs multi-line input (truncates at first \n), but 730KB when newlines are replaced with spaces (all text spoken). Emoji range approach inspired by kas-cor's upstream PR NousResearch#8205. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Thanks for the comprehensive normalization proposal. The underlying cleanup gap is still present on current main, but the salvage needs to cover a live sibling path and the current configuration surface. Problems
Suggested changes
Automated hermes-sweeper review. |
Address sweeper review (teknium1): - Add tts.preprocess and tts.language defaults to hermes_cli/config.py - Apply _preprocess_tts_text() to ElevenLabs streaming path (_speak_sentence), not just text_to_speech_tool() — streaming now gets emoji speech + pseudo-graphics stripping consistent with the sync path - Add 40 tests covering: markdown stripping, localized emoji speech (en/ru/de/ja), pseudo-graphics, MEDIA: removal, language detection chain, voice name extraction, emoji fallback chain, preprocess toggle
Fixes applied — addressing sweeper reviewThanks @teknium1 for the review! Pushed a fix ( 1. Centralize config-aware normalization for both pathsThe streaming path ( # In _speak_sentence (streaming path):
if tts_config.get("preprocess", True):
lang = _get_tts_language(tts_config)
cleaned = _preprocess_tts_text(sentence, lang=lang).strip()
else:
cleaned = _strip_markdown_for_tts(sentence).strip()
2. Add config defaultsAdded "tts": {
"provider": "edge",
"preprocess": True, # set false to pass raw text to TTS
"language": "", # ISO 639-1 code; "" = auto-detect
...
}3. Tests added (40 tests, all passing)New file:
|
|
Superseded by the broader normalizer from @AlexxRussell's #46497, merged in #73513 as the shared (Landed via #73513, merge |
Problem
When TTS reads text aloud, it pronounces markdown syntax, emoji characters, and pseudo-graphics literally — making voice messages sound robotic and unnatural across all platforms (Telegram, Discord, WhatsApp, CLI).
Solution
Add
_preprocess_tts_text()— a multilingual text normalization layer that runs before any TTS provider receives the text.Features
_get_tts_language():tts.languageconfig (explicit override)stt.local.languageconfig (reuse STT language — same user intent)ru-RU-SvetlanaNeural→ru)'en'as fallbackConfig options
If neither
tts.languagenorstt.local.languageis set, language is auto-detected from the configured TTS voice name.Supported languages
Testing
Impact
tts.preprocess: falseto disable