- Captions read message bodies only (the scraper was concatenating avatar, author and worklog chips); multi-segment interim turns are now speakable and drive clean speak-to-thinking-to-speak cycles when playback drains mid-turn. - Dynamic endpointing: complete-looking partials (3+ words or terminal punctuation) endpoint at the base window; the long hold remains only for one-two-word fragments. A stale-busy 10s settle wait on every post-error send is gone. - Both overlay captions are bounded, touch-scrollable regions with follow-tail; caps raised for long turns. - Error envelopes are never spoken or captioned; errored turns run resyncCapture (fresh STT session on the hot mic). - Workspace toggle now lives in the sidebar rail (floating button only below the rail breakpoint). - False 'session unavailable' toast root-caused: the router continuity guard shows it on a 409 that fired when a transient profile-listing failure failed closed into a fake cross-profile mismatch; the patcher now answers from the alias cache and never claims a default-vs-named mismatch while aliases are unconfirmed. - Barge-in sends carry a one-line cut-point marker with the last spoken sentence; visible-history truncation judged infeasible client-side. - Language switching works end-to-end: sticky per-session STT language hint (restarting an unused next session on switch), reply voice from script evidence, STT detection, then stopword heuristic; cues and WAV fallback share the turn language. - Legacy CI guards: node skip for the DOM probe, ffmpeg/codec skips for the container-fallback test. 272 voice-lane tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
879 lines
36 KiB
Python
879 lines
36 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"))
|
|
# The WebUI imports the pinned agent's STT tooling from the same image, so the
|
|
# local-command transcription envelope is patched alongside the WebUI itself.
|
|
AGENT_ROOT = Path(os.environ.get("HERMES_AGENT_PATCH_ROOT", "/opt/hermes"))
|
|
|
|
|
|
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")
|
|
|
|
|
|
def replace_between_exact(
|
|
path: Path, start: str, end: str, after: str = "", count: int = 1
|
|
) -> None:
|
|
"""Replace one exact, bounded upstream region and fail when the pin drifts."""
|
|
source = path.read_text(encoding="utf-8")
|
|
if source.count(start) != count or source.count(end) != count:
|
|
raise SystemExit(
|
|
f"Atlas voice patch context changed in {path}: {start[:80]!r}"
|
|
)
|
|
start_index = source.index(start)
|
|
end_index = source.index(end, start_index) + len(end)
|
|
path.write_text(
|
|
source[:start_index] + after + source[end_index:], encoding="utf-8"
|
|
)
|
|
|
|
|
|
def assert_absent(path: Path, *needles: str) -> None:
|
|
"""Fail the image build if a removed voice-choice surface remains."""
|
|
source = path.read_text(encoding="utf-8")
|
|
remaining = [needle for needle in needles if needle in source]
|
|
if remaining:
|
|
raise SystemExit(f"Atlas voice choice remains in {path}: {remaining!r}")
|
|
|
|
|
|
def remove_lines_containing(path: Path, *needles: str) -> None:
|
|
"""Remove all pinned translation entries for a retired settings control."""
|
|
source = path.read_text(encoding="utf-8")
|
|
for needle in needles:
|
|
if needle not in source:
|
|
raise SystemExit(f"Atlas voice patch context changed in {path}: {needle!r}")
|
|
lines = source.splitlines(keepends=True)
|
|
path.write_text(
|
|
"".join(line for line in lines if not any(n in line for n in needles)),
|
|
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,
|
|
'''<div class="settings-field"><label for="settingsTtsVoice" data-i18n="settings_label_tts_voice">Voice</label>
|
|
<select id="settingsTtsVoice" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
|
<option value="">Default system voice</option>
|
|
</select>
|
|
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tts_voice">Preferred voice. Populated from your browser's available voices.</div>
|
|
</div>''',
|
|
"",
|
|
)
|
|
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>''',
|
|
)
|
|
|
|
service_worker = ROOT / "static/sw.js"
|
|
replace_exact(
|
|
service_worker,
|
|
" './static/style.css' + VQ,\n",
|
|
" './static/style.css' + VQ,\n"
|
|
" './static/atlas-voice.css' + VQ,\n"
|
|
" './static/atlas-voice.js' + VQ,\n"
|
|
" './static/atlas-voice-worklet.js' + VQ,\n",
|
|
)
|
|
|
|
ui = ROOT / "static/ui.js"
|
|
replace_exact(
|
|
ui,
|
|
''' const savedVoice=localStorage.getItem('hermes-tts-voice');
|
|
const voices=speechSynthesis.getVoices();
|
|
if(savedVoice&&voices.length){
|
|
const match=voices.find(v=>v.name===savedVoice);
|
|
if(match) utter.voice=match;
|
|
}
|
|
''',
|
|
"",
|
|
)
|
|
replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){")
|
|
replace_exact(
|
|
ui,
|
|
" const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural';\n",
|
|
"",
|
|
)
|
|
replace_exact(
|
|
ui,
|
|
"body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})",
|
|
"body:JSON.stringify({text:chunk, rate:rate, pitch:pitch, engine:engineOverride||'edge'})",
|
|
)
|
|
replace_exact(
|
|
ui,
|
|
" voice: localStorage.getItem('hermes-tts-voice')||'',\n",
|
|
"",
|
|
count=2,
|
|
)
|
|
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);",
|
|
)
|
|
|
|
panels = ROOT / "static/panels.js"
|
|
replace_exact(panels, " tts_voice:'hermes-tts-voice',\n", "")
|
|
replace_exact(
|
|
panels,
|
|
''' const ttsVoiceSel=$('settingsTtsVoice');
|
|
if(ttsVoiceSel) _setOwnedSpeechPayload(payload,'tts_voice',ttsVoiceSel.value||'');
|
|
''',
|
|
"",
|
|
)
|
|
replace_exact(
|
|
panels,
|
|
''' localStorage.setItem('hermes-tts-engine',this.value);
|
|
window._populateTtsVoices();
|
|
_schedulePreferencesAutosave();''',
|
|
''' localStorage.setItem('hermes-tts-engine',this.value);
|
|
_schedulePreferencesAutosave();''',
|
|
)
|
|
replace_between_exact(
|
|
panels,
|
|
" // Populate voice selector based on engine\n",
|
|
" // TTS rate/pitch sliders\n",
|
|
" // TTS speaker selection is intentionally server policy only.\n",
|
|
)
|
|
replace_exact(
|
|
panels,
|
|
"let _settingsSpeechChangedKeys=new Set();\n",
|
|
"let _settingsSpeechChangedKeys=new Set();\n"
|
|
"try{localStorage.removeItem('hermes-tts-voice');}catch(_){}\n",
|
|
)
|
|
|
|
boot = ROOT / "static/boot.js"
|
|
replace_exact(
|
|
boot,
|
|
''' voice: localStorage.getItem("hermes-tts-voice")||'',
|
|
''',
|
|
"",
|
|
)
|
|
replace_exact(
|
|
boot,
|
|
''' const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural";
|
|
''',
|
|
"",
|
|
)
|
|
replace_exact(
|
|
boot,
|
|
" body: JSON.stringify({text: clean, voice, rate, pitch})",
|
|
" body: JSON.stringify({text: clean, rate, pitch})",
|
|
)
|
|
replace_exact(
|
|
boot,
|
|
''' const savedVoice=localStorage.getItem('hermes-tts-voice');
|
|
const voices=speechSynthesis.getVoices();
|
|
if(savedVoice&&voices.length){
|
|
const match=voices.find(v=>v.name===savedVoice);
|
|
if(match) utter.voice=match;
|
|
}
|
|
''',
|
|
"",
|
|
)
|
|
replace_exact(boot, " tts_voice:'',\n", "")
|
|
replace_exact(boot, " ['tts_voice','hermes-tts-voice'],\n", "")
|
|
|
|
config = ROOT / "api/config.py"
|
|
replace_exact(config, ' "tts_voice": "",\n', "")
|
|
replace_exact(config, ' "tts_voice",\n', "")
|
|
replace_exact(
|
|
config,
|
|
''' if k == "tts_voice":
|
|
if not isinstance(v, str) or len(v) > 200 or "\\x00" in v:
|
|
continue
|
|
''',
|
|
"",
|
|
)
|
|
|
|
assert_absent(index, "settingsTtsVoice", "settings_label_tts_voice")
|
|
assert_absent(ui, "hermes-tts-voice", "voice:voice")
|
|
assert_absent(panels, "settingsTtsVoice", "tts_voice")
|
|
assert_absent(boot, "hermes-tts-voice", "tts_voice", "text: clean, voice")
|
|
assert_absent(config, '"tts_voice"')
|
|
|
|
i18n = ROOT / "static/i18n.js"
|
|
remove_lines_containing(
|
|
i18n,
|
|
"settings_label_tts_voice:",
|
|
"settings_desc_tts_voice:",
|
|
)
|
|
assert_absent(i18n, "settings_label_tts_voice", "settings_desc_tts_voice")
|
|
|
|
# The private Whisper service reports the language it decoded with. Carry that
|
|
# through the agent's local-command STT envelope so the WebUI can hand a voice
|
|
# hint to Piper instead of guessing the reply's language from its text.
|
|
transcription = AGENT_ROOT / "tools/transcription_tools.py"
|
|
replace_exact(
|
|
transcription,
|
|
''' transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
|
|
logger.info(
|
|
"Transcribed %s via local STT command (%s, %d chars)",
|
|
Path(file_path).name,
|
|
normalized_model,
|
|
len(transcript_text),
|
|
)
|
|
return {"success": True, "transcript": transcript_text, "provider": "local_command"}
|
|
''',
|
|
''' transcript_text = txt_files[0].read_text(encoding="utf-8").strip()
|
|
logger.info(
|
|
"Transcribed %s via local STT command (%s, %d chars)",
|
|
Path(file_path).name,
|
|
normalized_model,
|
|
len(transcript_text),
|
|
)
|
|
detected_language = ""
|
|
language_files = sorted(Path(output_dir).glob("*.language"))
|
|
if language_files:
|
|
try:
|
|
candidate = language_files[0].read_text(encoding="utf-8").strip().lower()
|
|
except (OSError, ValueError):
|
|
candidate = ""
|
|
if 2 <= len(candidate) <= 3 and candidate.isascii() and candidate.isalpha():
|
|
detected_language = candidate
|
|
return {
|
|
"success": True,
|
|
"transcript": transcript_text,
|
|
"provider": "local_command",
|
|
"language": detected_language,
|
|
}
|
|
''',
|
|
)
|
|
|
|
upload = ROOT / "api/upload.py"
|
|
replace_exact(
|
|
upload,
|
|
""" transcript = str(result.get('transcript') or '').strip()
|
|
return j(handler, {'ok': True, 'transcript': transcript})
|
|
""",
|
|
""" transcript = str(result.get('transcript') or '').strip()
|
|
detected = str(result.get('language') or '').strip().lower()
|
|
if not (2 <= len(detected) <= 3 and detected.isascii() and detected.isalpha()):
|
|
detected = ''
|
|
return j(handler, {'ok': True, 'transcript': transcript, 'language': detected})
|
|
""",
|
|
)
|
|
|
|
routes = ROOT / "api/routes.py"
|
|
replace_exact(
|
|
routes,
|
|
"import html as _html\n",
|
|
"import base64\nimport html as _html\nimport secrets\n",
|
|
)
|
|
replace_exact(
|
|
routes,
|
|
"def _tts_open(req, *, timeout=30, opener_factory=None):",
|
|
'''ATLAS_TTS_LANGUAGES = ("en", "ru", "es")
|
|
ATLAS_TTS_CUE_IDS = ("thinking", "let_me_think", "still_working", "one_more_moment")
|
|
|
|
|
|
def _atlas_tts_language(body):
|
|
"""Return a plain, allow-listed en/ru/es code, or "" to send no language.
|
|
|
|
This is a trust boundary, not a parser. Only the exact normalized codes the
|
|
private Piper deployment bakes a voice for are forwarded; a missing field,
|
|
a wrong type, a region tag, padding, control characters, a traversal or
|
|
injection string, an oversized value, an object, an array, a number or a
|
|
client-supplied "voice" all resolve to "" and the language field is then
|
|
omitted entirely, so the Jetson service applies its own English default.
|
|
Coercing a malformed value into a supported code would let a browser
|
|
describe hostile input as a language we support; omission cannot.
|
|
"""
|
|
if not isinstance(body, dict):
|
|
return ""
|
|
value = body.get("language")
|
|
if not isinstance(value, str):
|
|
return ""
|
|
return value if value in ATLAS_TTS_LANGUAGES else ""
|
|
|
|
|
|
def _tts_open(req, *, timeout=30, opener_factory=None):''',
|
|
)
|
|
|
|
replace_exact(
|
|
routes,
|
|
"def _tts_open(req, *, timeout=30, opener_factory=None):",
|
|
'''ATLAS_TTS_STREAM_URL = "http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech/stream"
|
|
ATLAS_STT_STREAM_URL = "http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions/stream"
|
|
ATLAS_VOICE_PREFLIGHT_URL = "http://hermes-switchyard.hermes.svc.cluster.local:9009/voice/route-preflight"
|
|
ATLAS_VOICE_WS_PROTOCOL = "hermes-voice-v1"
|
|
ATLAS_VOICE_MAX_BYTES = 8 * 1024 * 1024
|
|
ATLAS_VOICE_DEADLINE_SECONDS = 100
|
|
ATLAS_VOICE_PREFLIGHT_TIERS = ("fast", "balanced", "deep", "maximum")
|
|
ATLAS_VOICE_PREFLIGHT_MAX_RESPONSE_BYTES = 1024
|
|
|
|
|
|
def _atlas_exact_stream_url(env_name, expected):
|
|
"""Return an exact private service URL, never a browser-selected target."""
|
|
configured = os.getenv(env_name, "").strip()
|
|
return configured if configured == expected else ""
|
|
|
|
|
|
def _handle_atlas_streaming_capability(handler):
|
|
"""Advertise only transports whose immutable in-cluster URLs are configured."""
|
|
tts = bool(_atlas_exact_stream_url("HERMES_WEBUI_ATLAS_TTS_STREAM_URL", ATLAS_TTS_STREAM_URL))
|
|
stt = bool(_atlas_exact_stream_url("HERMES_WEBUI_ATLAS_STT_STREAM_URL", ATLAS_STT_STREAM_URL))
|
|
preflight = True
|
|
j(handler, {
|
|
"tts": {
|
|
"available": tts,
|
|
"transport": "http",
|
|
"format": "pcm_s16le",
|
|
"sample_rate": 22050,
|
|
},
|
|
"stt": {
|
|
"available": stt,
|
|
"transport": "websocket",
|
|
"path": "/api/transcribe/stream",
|
|
"format": "pcm_s16le",
|
|
"sample_rate": 16000,
|
|
},
|
|
"preflight": {
|
|
"available": preflight,
|
|
"path": "/api/voice/route-preflight",
|
|
"advisory": True,
|
|
},
|
|
}, extra_headers={"Cache-Control": "no-store"})
|
|
return True
|
|
|
|
|
|
def _atlas_voice_preflight_payload(data):
|
|
"""Validate a provisional transcript without accepting routing authority."""
|
|
if not isinstance(data, dict):
|
|
raise ValueError("invalid request body")
|
|
turn_id = data.get("turn_id")
|
|
revision = data.get("revision")
|
|
transcript = data.get("transcript")
|
|
if not isinstance(turn_id, str) or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", turn_id):
|
|
raise ValueError("invalid turn_id")
|
|
if isinstance(revision, bool) or not isinstance(revision, int) or not 1 <= revision <= 1000000:
|
|
raise ValueError("invalid revision")
|
|
if not isinstance(transcript, str):
|
|
raise ValueError("invalid transcript")
|
|
transcript = " ".join(transcript.split())
|
|
if not 12 <= len(transcript) <= 512:
|
|
raise ValueError("invalid transcript")
|
|
return {"turn_id": turn_id, "revision": revision, "transcript": transcript}
|
|
|
|
|
|
def _atlas_voice_preflight_open(request, timeout=1.0):
|
|
"""Open only the immutable in-cluster advisory endpoint without proxies."""
|
|
return build_opener(ProxyHandler({}), _NoRedirectTtsHandler()).open(request, timeout=timeout)
|
|
|
|
|
|
def _handle_atlas_voice_preflight(handler):
|
|
"""Relay one same-origin local advisory and expose no classifier output."""
|
|
if not _check_same_origin_browser_request(handler):
|
|
return bad(handler, "Voice route preflight origin validation failed", 403)
|
|
target = ATLAS_VOICE_PREFLIGHT_URL
|
|
try:
|
|
payload = _atlas_voice_preflight_payload(read_body(handler))
|
|
except (TypeError, ValueError) as exc:
|
|
return bad(handler, str(exc), 400)
|
|
request = Request(
|
|
target,
|
|
data=json.dumps(payload, separators=(",", ":")).encode("utf-8"),
|
|
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
)
|
|
try:
|
|
with _atlas_voice_preflight_open(request, timeout=1.0) as upstream:
|
|
raw = upstream.read(ATLAS_VOICE_PREFLIGHT_MAX_RESPONSE_BYTES + 1)
|
|
if len(raw) > ATLAS_VOICE_PREFLIGHT_MAX_RESPONSE_BYTES:
|
|
raise ValueError("oversized advisory response")
|
|
result = json.loads(raw)
|
|
tier = result.get("tier") if isinstance(result, dict) else None
|
|
target_hint = result.get("target") if isinstance(result, dict) else None
|
|
if (
|
|
result.get("turn_id") != payload["turn_id"]
|
|
or result.get("revision") != payload["revision"]
|
|
or tier not in ATLAS_VOICE_PREFLIGHT_TIERS
|
|
or target_hint != "atlas/auto/" + tier
|
|
or result.get("advisory") is not True
|
|
):
|
|
raise ValueError("invalid advisory response")
|
|
except Exception:
|
|
# Advisory failure never changes or delays the final Switchyard request.
|
|
return bad(handler, "Voice route preflight unavailable", 503)
|
|
return j(
|
|
handler,
|
|
{
|
|
"turn_id": payload["turn_id"],
|
|
"revision": payload["revision"],
|
|
"tier": tier,
|
|
"target": target_hint,
|
|
"advisory": True,
|
|
},
|
|
extra_headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
|
|
def _atlas_tts_speed(body):
|
|
"""Clamp the optional client speech-rate to Piper's supported 0.5-2.0.
|
|
|
|
The value is a UX preference, not a trust decision: a missing, boolean,
|
|
non-numeric or NaN value falls back to the neutral 1.0 this proxy always
|
|
sent before the hands-free speed became client-tunable. Infinities clamp
|
|
to the range bounds like any other out-of-range number.
|
|
"""
|
|
if not isinstance(body, dict):
|
|
return 1.0
|
|
value = body.get("speed")
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
return 1.0
|
|
speed = float(value)
|
|
if speed != speed:
|
|
return 1.0
|
|
return max(0.5, min(2.0, speed))
|
|
|
|
|
|
def _atlas_tts_stream_payload(data):
|
|
"""Build the narrow Piper payload used by the raw-PCM stream endpoint."""
|
|
if not isinstance(data, dict):
|
|
raise ValueError("invalid request body")
|
|
text = data.get("text")
|
|
if not isinstance(text, str) or not text.strip():
|
|
raise ValueError("text is required")
|
|
text = text.strip()
|
|
if len(text) > 500:
|
|
raise ValueError("text too long (max 500 characters)")
|
|
payload = {"model": "piper", "input": text, "speed": _atlas_tts_speed(data)}
|
|
language = _atlas_tts_language(data)
|
|
if language:
|
|
payload["language"] = language
|
|
cue_id = data.get("cue_id")
|
|
if "cue_id" in data:
|
|
if not language or cue_id not in ATLAS_TTS_CUE_IDS:
|
|
raise ValueError("invalid thinking cue")
|
|
payload["cue_id"] = cue_id
|
|
turn_id = data.get("turn_id")
|
|
if isinstance(turn_id, str) and re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", turn_id):
|
|
payload["turn_id"] = turn_id
|
|
return payload
|
|
|
|
|
|
def _handle_atlas_tts_stream(handler):
|
|
"""Relay bounded private Piper PCM using HTTP/1.1 chunk framing."""
|
|
target = _atlas_exact_stream_url("HERMES_WEBUI_ATLAS_TTS_STREAM_URL", ATLAS_TTS_STREAM_URL)
|
|
if not target:
|
|
return bad(handler, "Atlas streaming TTS is not configured", 503)
|
|
headers_sent = False
|
|
try:
|
|
payload = _atlas_tts_stream_payload(read_body(handler))
|
|
except (TypeError, ValueError) as exc:
|
|
return bad(handler, str(exc), 400)
|
|
request = Request(
|
|
target,
|
|
data=json.dumps(payload).encode("utf-8"),
|
|
headers={"Content-Type": "application/json", "Accept": "audio/pcm"},
|
|
)
|
|
try:
|
|
upstream = _tts_open(
|
|
request,
|
|
timeout=45,
|
|
opener_factory=lambda: build_opener(ProxyHandler({}), _NoRedirectTtsHandler()),
|
|
)
|
|
with upstream:
|
|
content_type = str(upstream.headers.get("Content-Type", "")).split(";", 1)[0].lower()
|
|
if content_type not in ("audio/l16", "audio/pcm", "application/octet-stream"):
|
|
raise ValueError("Atlas streaming TTS returned an unsupported format")
|
|
try:
|
|
sample_rate = int(upstream.headers.get("X-Audio-Sample-Rate", "22050"))
|
|
channels = int(upstream.headers.get("X-Audio-Channels", "1"))
|
|
except (TypeError, ValueError):
|
|
raise ValueError("Atlas streaming TTS returned invalid audio metadata")
|
|
if not 8000 <= sample_rate <= 96000 or channels != 1:
|
|
raise ValueError("Atlas streaming TTS returned invalid audio metadata")
|
|
handler.send_response(200)
|
|
handler.send_header(
|
|
"Content-Type",
|
|
f"audio/pcm;rate={sample_rate};channels=1;encoding=signed-integer;bits=16;endian=little",
|
|
)
|
|
handler.send_header("X-Audio-Sample-Rate", str(sample_rate))
|
|
handler.send_header("X-Audio-Channels", "1")
|
|
handler.send_header("Cache-Control", "no-store")
|
|
handler.send_header("Transfer-Encoding", "chunked")
|
|
handler.end_headers()
|
|
headers_sent = True
|
|
sent = 0
|
|
while True:
|
|
chunk = upstream.read(16384)
|
|
if not chunk:
|
|
break
|
|
sent += len(chunk)
|
|
if sent > ATLAS_VOICE_MAX_BYTES:
|
|
raise ValueError("Atlas streaming TTS exceeded its audio limit")
|
|
handler.wfile.write(("%x\\r\\n" % len(chunk)).encode("ascii"))
|
|
handler.wfile.write(chunk)
|
|
handler.wfile.write(b"\\r\\n")
|
|
handler.wfile.flush()
|
|
handler.wfile.write(b"0\\r\\n\\r\\n")
|
|
handler.wfile.flush()
|
|
return True
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
return True
|
|
except Exception:
|
|
logger.exception("Atlas streaming TTS generation failed")
|
|
if not headers_sent:
|
|
return bad(handler, "Atlas streaming TTS generation failed", 502)
|
|
handler.close_connection = True
|
|
return True
|
|
|
|
|
|
def _atlas_ws_protocols(handler):
|
|
return [value.strip() for value in handler.headers.get("Sec-WebSocket-Protocol", "").split(",") if value.strip()]
|
|
|
|
|
|
def _atlas_ws_authorized(handler):
|
|
"""Require same-origin plus the rendered session CSRF token when enabled."""
|
|
origin = handler.headers.get("Origin", "").strip()
|
|
if not origin or not _check_same_origin_browser_request(handler):
|
|
return False
|
|
protocols = _atlas_ws_protocols(handler)
|
|
if ATLAS_VOICE_WS_PROTOCOL not in protocols:
|
|
return False
|
|
from api.auth import csrf_token_for_session, is_auth_enabled, parse_cookie, verify_session
|
|
if not is_auth_enabled():
|
|
return True
|
|
cookie = parse_cookie(handler)
|
|
if not cookie:
|
|
cookie = getattr(handler, "_trusted_auth_session_cookie_value", None)
|
|
if not cookie or not verify_session(cookie):
|
|
return False
|
|
expected = csrf_token_for_session(cookie) or ""
|
|
supplied = next((value.removeprefix("hermes-csrf.") for value in protocols if value.startswith("hermes-csrf.")), "")
|
|
return bool(expected and supplied and secrets.compare_digest(expected, supplied))
|
|
|
|
|
|
def _atlas_ws_upstream_handshake(target):
|
|
parsed = urlsplit(target)
|
|
if parsed.scheme != "http" or not parsed.hostname or parsed.port is None:
|
|
raise ValueError("invalid private streaming STT URL")
|
|
upstream = _socket.create_connection((parsed.hostname, parsed.port), timeout=5)
|
|
upstream.settimeout(5)
|
|
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
path = parsed.path or "/"
|
|
if parsed.query:
|
|
path += "?" + parsed.query
|
|
request = (
|
|
f"GET {path} HTTP/1.1\\r\\nHost: {parsed.hostname}:{parsed.port}\\r\\n"
|
|
"Upgrade: websocket\\r\\nConnection: Upgrade\\r\\n"
|
|
f"Sec-WebSocket-Key: {key}\\r\\nSec-WebSocket-Version: 13\\r\\n\\r\\n"
|
|
).encode("ascii")
|
|
upstream.sendall(request)
|
|
response = bytearray()
|
|
while b"\\r\\n\\r\\n" not in response and len(response) < 16384:
|
|
chunk = upstream.recv(4096)
|
|
if not chunk:
|
|
break
|
|
response.extend(chunk)
|
|
header, separator, remainder = bytes(response).partition(b"\\r\\n\\r\\n")
|
|
if not separator or not header.startswith(b"HTTP/1.1 101"):
|
|
upstream.close()
|
|
raise ConnectionError("private streaming STT rejected WebSocket upgrade")
|
|
expected = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii")).digest()).decode("ascii")
|
|
headers = {}
|
|
for line in header.split(b"\\r\\n")[1:]:
|
|
name, marker, value = line.partition(b":")
|
|
if marker:
|
|
headers[name.decode("ascii", "ignore").strip().lower()] = value.decode("ascii", "ignore").strip()
|
|
if not secrets.compare_digest(headers.get("sec-websocket-accept", ""), expected):
|
|
upstream.close()
|
|
raise ConnectionError("private streaming STT returned an invalid handshake")
|
|
upstream.settimeout(1)
|
|
return upstream, remainder
|
|
|
|
|
|
def _atlas_ws_relay(source, destination, stop, deadline):
|
|
transferred = 0
|
|
while not stop.is_set() and time.monotonic() < deadline:
|
|
try:
|
|
chunk = source.recv(16384)
|
|
except (_socket.timeout, TimeoutError):
|
|
continue
|
|
except OSError:
|
|
break
|
|
if not chunk:
|
|
break
|
|
transferred += len(chunk)
|
|
if transferred > ATLAS_VOICE_MAX_BYTES:
|
|
break
|
|
try:
|
|
destination.sendall(chunk)
|
|
except OSError:
|
|
break
|
|
stop.set()
|
|
|
|
|
|
def _handle_atlas_stt_stream(handler):
|
|
"""Bridge one authenticated same-origin browser WebSocket to private STT."""
|
|
target = _atlas_exact_stream_url("HERMES_WEBUI_ATLAS_STT_STREAM_URL", ATLAS_STT_STREAM_URL)
|
|
if not target:
|
|
return bad(handler, "Atlas streaming STT is not configured", 503)
|
|
if handler.headers.get("Upgrade", "").strip().lower() != "websocket":
|
|
return bad(handler, "WebSocket upgrade required", 426)
|
|
if not _atlas_ws_authorized(handler):
|
|
return bad(handler, "WebSocket origin or CSRF validation failed", 403)
|
|
browser_key = handler.headers.get("Sec-WebSocket-Key", "").strip()
|
|
if handler.headers.get("Sec-WebSocket-Version", "").strip() != "13":
|
|
return bad(handler, "WebSocket version 13 required", 426)
|
|
try:
|
|
decoded = base64.b64decode(browser_key, validate=True)
|
|
except Exception:
|
|
decoded = b""
|
|
if len(decoded) != 16:
|
|
return bad(handler, "Invalid WebSocket key", 400)
|
|
try:
|
|
upstream, remainder = _atlas_ws_upstream_handshake(target)
|
|
except Exception:
|
|
logger.exception("Atlas streaming STT connection failed")
|
|
return bad(handler, "Atlas streaming STT is unavailable", 502)
|
|
browser_accept = base64.b64encode(hashlib.sha1((browser_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii")).digest()).decode("ascii")
|
|
handler.send_response(101, "Switching Protocols")
|
|
handler.send_header("Upgrade", "websocket")
|
|
handler.send_header("Connection", "Upgrade")
|
|
handler.send_header("Sec-WebSocket-Accept", browser_accept)
|
|
handler.send_header("Sec-WebSocket-Protocol", ATLAS_VOICE_WS_PROTOCOL)
|
|
handler.end_headers()
|
|
handler.wfile.flush()
|
|
handler.close_connection = True
|
|
browser = handler.connection
|
|
browser.settimeout(1)
|
|
stop = threading.Event()
|
|
deadline = time.monotonic() + ATLAS_VOICE_DEADLINE_SECONDS
|
|
if remainder:
|
|
browser.sendall(remainder)
|
|
reverse = threading.Thread(
|
|
target=_atlas_ws_relay,
|
|
args=(upstream, browser, stop, deadline),
|
|
name="atlas-stt-ws-upstream",
|
|
daemon=True,
|
|
)
|
|
reverse.start()
|
|
try:
|
|
_atlas_ws_relay(browser, upstream, stop, deadline)
|
|
finally:
|
|
stop.set()
|
|
for sock in (upstream, browser):
|
|
try:
|
|
sock.shutdown(_socket.SHUT_RDWR)
|
|
except OSError:
|
|
pass
|
|
upstream.close()
|
|
reverse.join(timeout=2)
|
|
return True
|
|
|
|
|
|
def _tts_open(req, *, timeout=30, opener_factory=None):''',
|
|
)
|
|
|
|
replace_exact(
|
|
routes,
|
|
'''def handle_get(handler, parsed) -> bool:
|
|
"""Handle all GET routes. Returns True if handled, False for 404."""
|
|
''',
|
|
'''def handle_get(handler, parsed) -> bool:
|
|
"""Handle all GET routes. Returns True if handled, False for 404."""
|
|
if parsed.path == "/api/voice/streaming/capability":
|
|
return _handle_atlas_streaming_capability(handler)
|
|
if parsed.path == "/api/transcribe/stream":
|
|
return _handle_atlas_stt_stream(handler)
|
|
''',
|
|
)
|
|
|
|
replace_exact(
|
|
routes,
|
|
''' if parsed.path == "/api/transcribe":
|
|
return handle_transcribe(handler)
|
|
|
|
if parsed.path == "/api/tts":
|
|
return _handle_tts(handler, parsed)
|
|
''',
|
|
''' if parsed.path == "/api/transcribe":
|
|
return handle_transcribe(handler)
|
|
|
|
if parsed.path == "/api/voice/route-preflight":
|
|
return _handle_atlas_voice_preflight(handler)
|
|
|
|
if parsed.path == "/api/tts/stream":
|
|
return _handle_atlas_tts_stream(handler)
|
|
|
|
if parsed.path == "/api/tts":
|
|
return _handle_tts(handler, parsed)
|
|
''',
|
|
)
|
|
# The chat router's session-continuity poller renders whatever /api/session
|
|
# answers. Its 409 branch ("This session is unavailable to this account.")
|
|
# is only ever correct when the root-profile alias set is actually known —
|
|
# but list_profiles_api() is a hermes_cli subprocess call that fails
|
|
# transiently (cold pod after a roll, load spikes), and the pinned
|
|
# _is_root_profile() treats that failure as "not a root alias", flipping
|
|
# _profiles_match() to a false mismatch and painting the bogus banner over
|
|
# the input bar until the next successful listing. Two minimal grafts:
|
|
# answer alias checks from the last known alias set on listing failure, and
|
|
# never claim a default-vs-named mismatch while the alias set is unconfirmed.
|
|
profiles = ROOT / "api/profiles.py"
|
|
replace_exact(
|
|
profiles,
|
|
''' except Exception:
|
|
logger.debug("Failed to list profiles for root-profile lookup", exc_info=True)
|
|
return False
|
|
''',
|
|
''' except Exception:
|
|
logger.debug("Failed to list profiles for root-profile lookup", exc_info=True)
|
|
# Atlas voice patch: a transient listing failure must not deny a name
|
|
# that was already confirmed as a root alias — answer from the last
|
|
# known alias set instead of failing the alias outright.
|
|
with _root_profile_name_cache_lock:
|
|
return name in _root_profile_name_cache
|
|
''',
|
|
)
|
|
replace_exact(
|
|
profiles,
|
|
"def _is_root_profile(name: str) -> bool:\n",
|
|
'''def _root_profile_names_confirmed() -> bool:
|
|
"""True once list_profiles_api() has successfully populated the alias set.
|
|
|
|
Atlas voice patch: lets _profiles_match() distinguish "these profiles are
|
|
definitely different" from "the root-alias equivalence could not be
|
|
checked yet" (cold cache right after a pod roll, or a failing hermes_cli
|
|
listing), which previously produced transient /api/session 409s.
|
|
"""
|
|
with _root_profile_name_cache_lock:
|
|
return _root_profile_name_cache_loaded
|
|
|
|
|
|
def _is_root_profile(name: str) -> bool:
|
|
''',
|
|
)
|
|
replace_exact(
|
|
profiles,
|
|
''' # Cross-alias the renamed root.
|
|
if _is_root_profile(row) and _is_root_profile(active):
|
|
return True
|
|
return False
|
|
''',
|
|
''' # Cross-alias the renamed root.
|
|
if _is_root_profile(row) and _is_root_profile(active):
|
|
return True
|
|
# Atlas voice patch: while the root alias set is unconfirmed (cold cache
|
|
# after a pod roll, hermes_cli listing failure) a pair involving the
|
|
# 'default' alias cannot be *proven* mismatched — the named side may be
|
|
# the renamed root. Fail open for that pair only: a mismatch between two
|
|
# named profiles is still denied, and exact scoping resumes with the
|
|
# first successful listing. This removes the transient /api/session 409
|
|
# that rendered a false "unavailable to this account" banner.
|
|
if "default" in (row, active) and not _root_profile_names_confirmed():
|
|
return True
|
|
return False
|
|
''',
|
|
)
|
|
|
|
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
|
|
if isinstance(data, dict) and "speed" in data:
|
|
# Hands-free clients send an explicit validated speed; it wins
|
|
# over the legacy percentage rate string.
|
|
speed = _atlas_tts_speed(data)
|
|
request_payload = {
|
|
"model": "piper",
|
|
"input": text,
|
|
"speed": speed,
|
|
}
|
|
# Attach a language ONLY when the browser sent a plain allow-listed
|
|
# code. Omitting it is the fail-safe: the Jetson service then speaks
|
|
# its own English default, which is also what every partially rolled
|
|
# out combination of these components degrades to.
|
|
_atlas_language = _atlas_tts_language(data)
|
|
if _atlas_language:
|
|
request_payload["language"] = _atlas_language
|
|
request_body = json.dumps(request_payload).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)
|