atlas-iac/dockerfiles/hermes-webui-atlas-patch.py
Hermes Agent cf7535c502 feat(hermes-tts): multilingual Piper voice policy (en=amy, ru=irina, es=claude)
Supersedes draft PR #24 (hermes/tts-voice-hfc-female): Brad changed the
decision after that task landed, so this starts fresh from origin/main
instead of building on it.

Bakes three checksum-pinned Piper voices (en_US-amy-medium,
ru_RU-irina-medium, es_MX-claude-high) alongside the existing lessac set,
and adds deterministic, allow-listed language routing to
hermes-jetson-tts-server.py: an explicit request "language" field maps
through a fixed dict to one of the three baked voices, with unknown,
missing, or malformed input always falling back to English amy. A
client-supplied "voice" field is never read, so no client input can reach
a filesystem path. All three voices are eagerly preloaded at process
start (measured ~243MB RSS for three vs. ~88MB for one).

The WebUI has no signal for the language of the text it is about to
speak (verified: hermes-webui-atlas-voice.js sends only text and engine),
so no client- or server-side language detection is added; this gap is
documented in NOTES.md and the PR description rather than papered over.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 18:53:43 +00:00

101 lines
4.4 KiB
Python

#!/usr/bin/env python3
"""Apply fail-closed Atlas voice integration patches to pinned Hermes WebUI."""
from pathlib import Path
ROOT = Path("/opt/hermes-webui")
def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
"""Replace an exact upstream fragment and fail when the pin has drifted."""
source = path.read_text(encoding="utf-8")
if source.count(before) != count:
raise SystemExit(f"Atlas voice patch context changed in {path}: {before[:80]!r}")
path.write_text(source.replace(before, after, count), encoding="utf-8")
index = ROOT / "static/index.html"
replace_exact(
index,
'<option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option>',
'<option value="atlas">Atlas Jetson (private)</option><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option>',
)
replace_exact(
index,
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>',
'<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>\n<script src="static/atlas-voice.js?v=__WEBUI_VERSION__" defer></script>',
)
ui = ROOT / "static/ui.js"
replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){")
replace_exact(
ui,
"body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})",
"body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch, engine:engineOverride||'edge'})",
)
replace_exact(
ui,
"if(engine==='edge'){\n _playEdgeTtsChunked(clean, btn);",
"if(engine==='edge'||engine==='atlas'){\n _playEdgeTtsChunked(clean, btn, engine);",
)
replace_exact(
ui,
"if(engine==='edge'){\n _playEdgeTtsChunked(clean, null);",
"if(engine==='edge'||engine==='atlas'){\n _playEdgeTtsChunked(clean, null, engine);",
)
routes = ROOT / "api/routes.py"
marker = " # ── ElevenLabs TTS ──────────────────────────────────────────────────\n"
atlas = ''' # ── Atlas private Jetson TTS ─────────────────────────────────────────
if engine == "atlas":
atlas_url = os.getenv("HERMES_WEBUI_ATLAS_TTS_URL", "").strip()
expected_url = "http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech"
if atlas_url != expected_url:
from api.helpers import bad as _bad
return _bad(handler, "Atlas private TTS is not configured", 503)
speed = 1.0
if rate_str:
try:
speed = max(0.5, min(2.0, 1.0 + (float(rate_str.rstrip("%")) / 100.0)))
except ValueError:
speed = 1.0
# No "voice" or "language" field: the WebUI has no signal for the
# language of the text being spoken (see NOTES.md), so voice
# selection is left entirely to the TTS service's own allow-listed
# policy (English amy) rather than sending a value that would only
# be ignored server-side or a fabricated language guess.
request_body = json.dumps({
"model": "piper",
"input": text,
"speed": speed,
}).encode("utf-8")
request = Request(atlas_url, data=request_body, headers={
"Content-Type": "application/json",
"Accept": "audio/wav",
})
try:
with _tts_open(
request,
timeout=45,
opener_factory=lambda: build_opener(ProxyHandler({}), _NoRedirectTtsHandler()),
) as response:
audio_data = _buffer_tts_audio_response(response)
except Exception:
logger.exception("Atlas private TTS generation failed")
from api.helpers import bad as _bad
return _bad(handler, "Atlas private TTS generation failed", 502)
handler.send_response(200)
handler.send_header("Content-Type", "audio/wav")
handler.send_header("Cache-Control", "no-store")
handler.send_header("Content-Length", str(len(audio_data)))
handler.end_headers()
try:
handler.wfile.write(audio_data)
except (BrokenPipeError, ConnectionResetError):
pass
return True
'''
replace_exact(routes, marker, atlas + marker)