atlas-iac/dockerfiles/hermes-webui-atlas-patch.py

131 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Apply fail-closed Atlas voice integration patches to pinned Hermes WebUI."""
import os
from pathlib import Path
ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/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,
'<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">',
'<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">\n'
'<link id="voiceInstrumentStyles" rel="stylesheet" '
'href="static/atlas-voice.css?v=__WEBUI_VERSION__">',
)
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>',
)
replace_exact(
index,
''' <div class="voice-mode-bar" id="voiceModeBar" style="display:none">
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
<span class="voice-mode-label" id="voiceModeLabel"></span>
</div>''',
''' <div class="voice-mode-bar" id="voiceModeBar" style="display:none" role="status" aria-live="polite" aria-atomic="true">
<span class="voice-mode-indicator idle" id="voiceModeIndicator" aria-hidden="true">
<span class="voice-instrument-halo"></span>
<span class="voice-instrument-ripple"></span>
<span class="voice-instrument-orbit"></span>
<span class="voice-instrument-core">
<span class="voice-instrument-symbol">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" focusable="false">
<g class="voice-symbol voice-symbol-listening"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M6.5 11.5a5.5 5.5 0 0 0 11 0M12 17v3M9 20h6"/></g>
<g class="voice-symbol voice-symbol-transcribing"><path d="M5 7h14M5 12h10M5 17h7"/><path d="M18 15v5m-2.5-2.5L18 20l2.5-2.5"/></g>
<g class="voice-symbol voice-symbol-thinking"><path d="M12 3l1.15 4.1L17 8.5l-3.85 1.4L12 14l-1.15-4.1L7 8.5l3.85-1.4L12 3Z"/><path d="M18.5 13.5l.65 2.35 2.35.65-2.35.65-.65 2.35-.65-2.35-2.35-.65 2.35-.65.65-2.35Z"/><path d="M5.5 14l.45 1.55L7.5 16l-1.55.45L5.5 18l-.45-1.55L3.5 16l1.55-.45L5.5 14Z"/></g>
<g class="voice-symbol voice-symbol-speaking"><path d="M5 10v4h3l4 3V7L8 10H5Z"/><path d="M15.5 9.25a4 4 0 0 1 0 5.5M18 7a7 7 0 0 1 0 10"/></g>
<g class="voice-symbol voice-symbol-error"><path d="M12 4 21 20H3L12 4Z"/><path d="M12 9v5M12 17.2v.1"/></g>
</svg>
</span>
</span>
</span>
<span class="voice-mode-label" id="voiceModeLabel"></span>
</div>''',
)
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
request_body = json.dumps({
"model": "piper",
"input": text,
"voice": "en_US-lessac-high",
"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)