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>
135 lines
6.7 KiB
Python
135 lines
6.7 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
|
|
# 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)
|