Skip to content

feat(tts): add text preprocessing for natural speech output - #8205

Closed
kas-cor wants to merge 7 commits into
NousResearch:mainfrom
kas-cor:tts-preprocess
Closed

feat(tts): add text preprocessing for natural speech output#8205
kas-cor wants to merge 7 commits into
NousResearch:mainfrom
kas-cor:tts-preprocess

Conversation

@kas-cor

@kas-cor kas-cor commented Apr 12, 2026

Copy link
Copy Markdown
Contributor

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

  1. Strips markdown: code blocks, bold/italic, inline code, links, headings, horizontal rules
  2. Localized emoji speech: 28 emojis with spoken equivalents in 13 languages (en, ru, de, es, fr, pt, ja, zh, ko, it, pl, uk, tr)
  3. Smart language detection via _get_tts_language():
    • tts.language config (explicit override)
    • stt.local.language config (reuse STT language — same user intent)
    • TTS voice name extraction (e.g. ru-RU-SvetlanaNeuralru)
    • 'en' as fallback
  4. Strips remaining Unicode emojis (10 major ranges covered)
  5. Converts box-drawing chars and table pipes to pauses/spaces
  6. Removes MEDIA: tags, collapses whitespace

Config options

tts:
  preprocess: true    # enable/disable text preprocessing (default: true)
  language: ru        # explicit override for TTS preprocessing language

If neither tts.language nor stt.local.language is set, language is auto-detected from the configured TTS voice name.

Supported languages

Code Language ⬆️ ⚠️
en English done update warning
ru Russian готово обновление внимание
de German fertig Update Warnung
es Spanish hecho actualización atención
fr French fait mise à jour attention
pt Portuguese feito atualização atenção
ja Japanese 完了 更新 警告
zh Chinese 完成 更新 注意
ko Korean 완료 업데이트 주의
it Italian fatto aggiornamento attenzione
pl Polish gotowe aktualizacja uwaga
uk Ukrainian готово оновлення увага
tr Turkish tamam güncelleme dikkat

Testing

from tools.tts_tool import _preprocess_tts_text, _get_tts_language, _load_tts_config

# Auto-detect language from config (voice = ru-RU-SvetlanaNeural)
config = _load_tts_config()
lang = _get_tts_language(config)  # → 'ru'

# Russian: emoji speech in Russian
_preprocess_tts_text('✅ **Обновлено** до v0.8 ⬆️', lang='ru')
# → 'готово Обновлено до v0.8 обновление'

# English: emoji speech in English
_preprocess_tts_text('✅ **Updated** to v0.8 ⬆️', lang='en')
# → 'done Updated to v0.8 update'

# Table pipes → spaces
_preprocess_tts_text('| Name | Value |', lang='en')
# → 'Name Value'

Impact

  • Zero config changes needed — enabled by default, works out of the box
  • All 6 TTS providers supported (Edge, ElevenLabs, OpenAI, MiniMax, Mistral, NeuTTS)
  • No breaking changes — config load moved slightly earlier, no functional impact
  • 28 emojis with localized speech in 13 languages, ~30 emojis silently removed
  • Config toggle: tts.preprocess: false to disable

kas-cor added 5 commits April 12, 2026 09:35
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.
malaiwah pushed a commit to malaiwah/hermes-agent that referenced this pull request Apr 13, 2026
- 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>
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have tool/tts Text-to-speech and transcription labels Apr 28, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Related to #13311 (open) and #9005 (closed) — all three add TTS text preprocessing. This PR is the most comprehensive (13-language emoji localization, language detection). Consider consolidating with #13311.

@teknium1

Copy link
Copy Markdown
Contributor

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

  • The new invocation is only in text_to_speech_tool(), while ElevenLabs streaming calls _strip_markdown_for_tts() directly at tools/tts_tool.py:2674; that path would still send emoji and pseudo-graphics to its provider.
  • The PR documents tts.preprocess and tts.language, but current defaults live in hermes_cli/config.py:2066-2123 and the diff does not add either key there.
  • No tests are added. Existing coverage in tests/tools/test_voice_cli_integration.py:48-123 covers basic Markdown stripping only.

Suggested changes

  • Centralize config-aware normalization and apply it to both synchronous and streaming provider submission paths.
  • Add defaults plus focused behavior tests, including preprocess: false, language resolution, and streaming TTS.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 12, 2026
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
@kas-cor

kas-cor commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Fixes applied — addressing sweeper review

Thanks @teknium1 for the review! Pushed a fix (c111062) addressing all three points:

1. Centralize config-aware normalization for both paths

The streaming path (_speak_sentence in stream_tts_to_speaker()) was calling _strip_markdown_for_tts() directly, bypassing emoji speech substitution and pseudo-graphics stripping. Now both paths use _preprocess_tts_text():

# 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()
  • text_to_speech_tool() — already had preprocessing (unchanged)
  • stream_tts_to_speaker()_speak_sentence()now uses _preprocess_tts_text() when preprocess: true (default)

2. Add config defaults

Added preprocess and language to the TTS defaults in hermes_cli/config.py:

"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: tests/tools/test_tts_preprocess.py

Suite Tests Coverage
TestPreprocessBasic 9 markdown, code blocks, links, headers, MEDIA: tags, whitespace
TestEmojiSpeech 10 localized emoji speech (en/ru/de/ja), multiple emojis, silent removal
TestPseudoGraphics 2 table pipes, box-drawing chars
TestGetTtsLanguage 7 explicit override, STT fallback, voice extraction, empty string, fallback en
TestDetectLangFromVoice 6 Edge/OpenAI voice names, short names, empty/None
TestLookupEmojiSpeech 5 exact match, en fallback, silent, unknown, lang prefix
TestPreprocessDisabled 1 preprocess toggle sanity
============================== 40 passed in 1.49s ==============================

@teknium1

Copy link
Copy Markdown
Contributor

Superseded by the broader normalizer from @AlexxRussell's #46497, merged in #73513 as the shared prepare_spoken_text cleaner. Thanks for the early work on this!

(Landed via #73513, merge 4aac89b429.) Closing.

@teknium1 teknium1 closed this Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades tool/tts Text-to-speech and transcription type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants