fix(tts): split long speech by provider and platform limits

Salvage of PR #17973 by @TKCen (Sebastian Hänisch), re-implemented on
current main to preserve speed/instructions/provider params,
prepare_spoken_text normalization, OPUS_VOICE_PLATFORMS, is_write_denied
path security, microsecond timestamps, and the streaming-TTS gate.

- Split long TTS text into provider-safe chunks instead of truncating
- Pack generated audio against platform upload limits (Discord 10MB,
  Telegram 50MB, configurable via tts.delivery_profiles)
- Combine chunks with ffmpeg (OGG/Opus re-encoded, MP3 stream-copied)
- Multi-file delivery when combination fails or would exceed limits
- Remove hard [:4000] truncation from all callers (cli.py, voice.py,
  gateway/run.py, gateway/platforms/base.py)
- Gemini TTS raises ValueError instead of silently truncating when
  composed prompt exceeds the provider limit

Simplify-code fixes: removed dead all_touched_paths set, added
try/finally for scratch file cleanup on exception, clean error response
on chunk failure instead of leaking stale file_path.
This commit is contained in:
kshitij
2026-08-08 19:11:04 +05:30
parent 0c2cdccccc
commit 73997c41bb
10 changed files with 917 additions and 165 deletions
+20 -24
View File
@@ -12575,14 +12575,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# Strip markdown and non-speech content for cleaner TTS via the
# shared cleaner (tools/tts_text_normalize): markdown, emoji,
# <think> blocks, verifier footer, units, newline flattening.
# ⋗ blocks, verifier footer, units, newline flattening.
# The TTS tool owns provider request limits and long-form chunking.
try:
from tools.tts_text_normalize import prepare_spoken_text
tts_text = prepare_spoken_text(text, max_chars=4000)
tts_text = prepare_spoken_text(text, max_chars=None)
except Exception:
# Legacy fallback pipeline — keep voice replies best-effort.
tts_text = text[:4000] if len(text) > 4000 else text
tts_text = re.sub(r'```[\s\S]*?```', ' ', tts_text) # fenced code blocks
tts_text = re.sub(r'```[\s\S]*?```', ' ', text) # fenced code blocks
tts_text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', tts_text) # [text](url) -> text
tts_text = re.sub(r'https?://\S+', '', tts_text) # URLs
tts_text = re.sub(r'\*\*(.+?)\*\*', r'\1', tts_text) # bold
@@ -12611,26 +12611,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
except Exception:
tts_result = {}
# Prefer the requested MP3 when the provider produced it. This
# preserves reliable local playback while still supporting
# providers that write to and return a different path.
audio_path = mp3_path
if not os.path.isfile(mp3_path) or os.path.getsize(mp3_path) == 0:
audio_path = tts_result.get("file_path") or mp3_path
if os.path.isfile(audio_path) and os.path.getsize(audio_path) > 0:
play_audio_file(audio_path)
# Clean up
try:
cleanup_paths = {audio_path, mp3_path}
for path in list(cleanup_paths):
ogg_path = path.rsplit(".", 1)[0] + ".ogg"
cleanup_paths.add(ogg_path)
for path in cleanup_paths:
if os.path.isfile(path):
os.unlink(path)
except OSError:
pass
# The tool result is authoritative — it may return multiple files
# for long-form chunked output. Play each in order.
play_paths = tts_result.get("file_paths") or [
tts_result.get("file_path") or mp3_path
]
for play_path in play_paths if tts_result.get("success") else []:
if os.path.isfile(play_path) and os.path.getsize(play_path) > 0:
play_audio_file(play_path)
# Clean up all generated files (play_paths + mp3_path + ogg variants)
cleanup_paths = set(play_paths + [mp3_path, mp3_path.rsplit(".", 1)[0] + ".ogg"])
for path in cleanup_paths:
if os.path.isfile(path):
try:
os.unlink(path)
except OSError:
pass
except Exception as e:
logger.warning("Voice TTS playback failed: %s", e)
_cprint(f"{_DIM}TTS playback failed: {e}{_RST}")