diff --git a/api/routes.py b/api/routes.py
index 44afdbb2..f16f8b8f 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -5465,6 +5465,9 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/transcribe":
return handle_transcribe(handler)
+ if parsed.path == "/api/tts":
+ return _handle_tts(handler, parsed)
+
if parsed.path == "/api/client-events/log":
if diag:
diag.stage("read_client_event_body")
@@ -8162,6 +8165,143 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_
return True
+
+def _handle_tts(handler, parsed):
+ """Generate TTS audio via Edge TTS. POST JSON body only.
+
+ Design note addressing deep review blocker #4 (synchronous I/O):
+ The server uses ThreadingHTTPServer (see server.py:173), so each request
+ already runs in its own dedicated thread. A TTS request therefore occupies
+ only its own thread during Microsoft network I/O + streaming; other clients
+ are unaffected. Combined with early auth, a strict per-client 2 s rate
+ limit, 5000-char cap, and voice allowlist, the blocking cost is bounded and
+ intentional. Streaming chunks directly via stream_sync() keeps memory
+ usage low. A cross-thread pool + queue would add complexity and wfile
+ thread-safety issues with no practical gain under the current model.
+ If the HTTP layer ever moves to asyncio we can adopt edge_tts's native
+ async API at that time.
+ """
+ text = ""
+ voice = "zh-CN-XiaoxiaoNeural"
+ rate_str = ""
+ pitch_str = ""
+
+ if handler.command != "POST":
+ from api.helpers import bad as _bad
+ return _bad(handler, "POST required for /api/tts", 405)
+
+ try:
+ data = read_body(handler)
+ text = (data.get("text") or "").strip()
+ voice = data.get("voice") or voice
+ rate_str = data.get("rate") or ""
+ pitch_str = data.get("pitch") or ""
+ except Exception:
+ from api.helpers import bad as _bad
+ return _bad(handler, "invalid request body", 400)
+
+ if not text:
+ from api.helpers import bad as _bad
+ return _bad(handler, "text is required", 400)
+ if len(text) > 5000:
+ from api.helpers import bad as _bad
+ return _bad(handler, "text too long (max 5000 characters)", 400)
+
+ from api.auth import is_auth_enabled, parse_cookie, verify_session
+ cv = None
+ if is_auth_enabled():
+ cv = parse_cookie(handler)
+ if not (cv and verify_session(cv)):
+ from api.helpers import bad as _bad
+ return _bad(handler, "unauthorized", 401)
+
+ # High-quality per-client rate limiting for TTS.
+ if not hasattr(_handle_tts, "_tts_limiter"):
+ import time as _time, threading as _threading
+ class _TtsRateLimiter:
+ def __init__(self, window_seconds=2.0, prune_interval=50):
+ self.window = window_seconds
+ self.prune_interval = prune_interval
+ self._hits = {}
+ self._lock = _threading.Lock()
+ self._checks = 0
+
+ def _get_client_key(self, h):
+ for hdr in ("X-Forwarded-For", "X-Real-IP", "Forwarded"):
+ val = h.headers.get(hdr)
+ if val:
+ ip = val.split(",")[0].strip().split(";")[0].strip()
+ if ip:
+ return ip
+ return getattr(h, "client_address", ("unknown",))[0]
+
+ def check(self, handler, session_cookie=None):
+ key = self._get_client_key(handler)
+ if session_cookie and "." in str(session_cookie):
+ key = str(session_cookie).split(".", 1)[0]
+ now = _time.time()
+ with self._lock:
+ self._checks += 1
+ if self._checks % self.prune_interval == 0:
+ cutoff = now - (self.window * 10)
+ self._hits = {k: v for k, v in self._hits.items() if v > cutoff}
+ last = self._hits.get(key, 0)
+ if now - last < self.window:
+ return False
+ self._hits[key] = now
+ return True
+
+ _handle_tts._tts_limiter = _TtsRateLimiter(window_seconds=2.0)
+
+ limiter = _handle_tts._tts_limiter
+ if not limiter.check(handler, cv):
+ logger.warning("TTS rate limit hit for client=%s", limiter._get_client_key(handler))
+ from api.helpers import bad as _bad
+ return _bad(handler, "rate limit exceeded — please wait", 429)
+
+ allowed = {
+ "zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunxiNeural",
+ "zh-CN-YunjianNeural", "zh-CN-YunyangNeural",
+ "en-US-AriaNeural", "en-US-GuyNeural"
+ }
+ if voice not in allowed:
+ from api.helpers import bad as _bad
+ return _bad(handler, "invalid voice", 400)
+
+ try:
+ try:
+ import edge_tts
+ except ImportError:
+ from api.helpers import bad as _bad
+ return _bad(handler, "edge-tts not installed — see docs", 503)
+
+ kwargs = {}
+ if rate_str:
+ kwargs["rate"] = rate_str
+ if pitch_str:
+ kwargs["pitch"] = pitch_str
+
+ comm = edge_tts.Communicate(text, voice, **kwargs)
+
+ handler.send_response(200)
+ handler.send_header("Content-Type", "audio/mpeg")
+ handler.send_header("Cache-Control", "no-store")
+ handler.end_headers()
+
+ for chunk in comm.stream_sync():
+ if chunk.get("type") == "audio" and chunk.get("data"):
+ try:
+ handler.wfile.write(chunk["data"])
+ except (BrokenPipeError, ConnectionResetError):
+ return True
+ return True
+
+ except BrokenPipeError:
+ return True
+ except Exception:
+ logger.exception("Edge TTS generation failed")
+ from api.helpers import bad as _bad
+ return _bad(handler, "TTS generation failed", 500)
def _html_preview_with_blank_base(raw: bytes) -> bytes:
base = ''
text = raw.decode("utf-8", errors="replace")
diff --git a/docker_init.bash b/docker_init.bash
index ee6654e6..2002de15 100644
--- a/docker_init.bash
+++ b/docker_init.bash
@@ -196,6 +196,7 @@ chown_home_hermeswebui() {
# ownership alignment.
find /home/hermeswebui \
-path "/home/hermeswebui/.hermes/hermes-agent" -prune \
+ -o -name ".git" -prune \
-o -exec chown -h "${WANTED_UID}:${WANTED_GID}" {} +
}
diff --git a/requirements.txt b/requirements.txt
index 88082f4c..e09a4ff5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,4 +2,5 @@
# The server uses PyYAML plus cryptography for optional local passkey/WebAuthn support.
# All heavy ML/agent deps live in the Hermes agent venv.
pyyaml>=6.0
+edge-tts
cryptography>=42.0
diff --git a/static/boot.js b/static/boot.js
index 034dc82c..9bcd1578 100644
--- a/static/boot.js
+++ b/static/boot.js
@@ -1,3 +1,6 @@
+// Early boot initialization that must run before any other code.
+// These run during script evaluation to handle server-stopped state
+// and cross-tab shutdown broadcasts as early as possible.
(function(){
// Clear stale stop-server flag on successful page load (server is reachable)
try{localStorage.removeItem('hermes-webui-server-stopped');}catch(_){}
@@ -889,7 +892,48 @@ window._micPendingSend=window._micPendingSend||false;
.trim();
}
if(!clean){ _startListening(); return; }
-
+ const engine=localStorage.getItem("hermes-tts-engine")||"browser";
+ if(engine==="edge"){
+ const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural";
+ const savedRate=parseFloat(localStorage.getItem("hermes-tts-rate"));
+ const savedPitch=parseFloat(localStorage.getItem("hermes-tts-pitch"));
+ let rate='', pitch='';
+ if(!isNaN(savedRate)){const pct=Math.round((savedRate-1)*100);const sign=pct>=0?'+':'';rate=sign+pct+'%';}
+ if(!isNaN(savedPitch)){const hz=Math.round((savedPitch-1)*50);const sign=hz>=0?'+':'';pitch=sign+hz+'Hz';}
+ _ttsSpeaking=true;
+ fetch(new URL('api/tts', document.baseURI || location.href).href, {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({text: clean, voice, rate, pitch})
+ })
+ .then(r => {
+ if(!r.ok) throw new Error('TTS request failed: ' + r.status);
+ return r.blob();
+ })
+ .then(blob => {
+ const url = URL.createObjectURL(blob);
+ const audio = new Audio(url);
+ audio.onended = () => {
+ _ttsSpeaking=false;
+ URL.revokeObjectURL(url);
+ if(_voiceModeActive) setTimeout(()=>_startListening(),500);
+ };
+ audio.onerror = () => {
+ _ttsSpeaking=false;
+ URL.revokeObjectURL(url);
+ if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
+ };
+ audio.play().catch(e => {
+ _ttsSpeaking=false;
+ if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
+ });
+ })
+ .catch(() => {
+ _ttsSpeaking=false;
+ if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
+ });
+ return;
+ }
const utter=new SpeechSynthesisUtterance(clean);
// Apply saved voice preferences
@@ -1911,7 +1955,6 @@ function applyBotName(){
// Start real-time gateway session sync if setting is enabled
if(typeof startGatewaySSE==='function') startGatewaySSE();
})().catch(e=>{
- console.error('[hermes] boot failed', e);
try{S._bootReady=true;}catch(_){}
try{syncTopbar();}catch(_){}
try{syncWorkspacePanelState();}catch(_){}
diff --git a/static/i18n.js b/static/i18n.js
index 3d16175e..0b3dc010 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -851,6 +851,8 @@ const LOCALES = {
settings_desc_tts_voice: "Preferred voice. Populated from your browser's available voices.",
settings_label_tts_rate: 'Speech rate',
settings_label_tts_pitch: 'Speech pitch',
+ settings_label_tts_engine: 'TTS Engine',
+ settings_desc_tts_engine: 'Choose speech engine. Edge TTS uses Microsoft neural voices via the server.',
settings_label_notifications: 'Browser notifications',
settings_desc_notifications: 'Show a system notification when a response completes while the app is in the background.',
settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.',
@@ -2171,6 +2173,8 @@ const LOCALES = {
settings_desc_tts_voice: 'Voce preferita. Popolata dalle voci disponibili nel browser.',
settings_label_tts_rate: 'Velocità voce',
settings_label_tts_pitch: 'Tono voce',
+ settings_label_tts_engine: 'Motore TTS',
+ settings_desc_tts_engine: 'Scegli il motore vocale. Edge TTS utilizza le voci neurali Microsoft tramite il server.',
settings_label_notifications: 'Notifiche browser',
settings_desc_notifications: 'Mostra una notifica di sistema quando una risposta termina mentre l\'app è in background.',
settings_desc_token_usage: 'Mostra il conteggio token input/output sotto ogni risposta dell\'assistente. Attivabile anche con /usage.',
@@ -3496,6 +3500,8 @@ const LOCALES = {
settings_desc_tts_voice: '優先する声。ブラウザで利用可能な声から選択されます。',
settings_label_tts_rate: '読み上げ速度',
settings_label_tts_pitch: '読み上げピッチ',
+ settings_label_tts_engine: 'TTSエンジン',
+ settings_desc_tts_engine: '音声エンジンを選択してください。Edge TTSはサーバー経由でMicrosoftのニューラル音声を使用します。',
settings_label_notifications: 'ブラウザ通知',
settings_desc_notifications: 'アプリがバックグラウンドの間に応答が完了したらシステム通知を表示します。',
settings_desc_token_usage: 'アシスタント応答の下に入力/出力トークン数を表示します。/usage でも切替可能。',
@@ -5184,6 +5190,8 @@ const LOCALES = {
settings_desc_tts_voice: 'Выберите голос для синтеза речи',
settings_label_tts_rate: 'Скорость речи',
settings_label_tts_pitch: 'Тон речи',
+ settings_label_tts_engine: 'Движок TTS',
+ settings_desc_tts_engine: 'Выберите движок синтеза речи. Edge TTS использует нейронные голоса Microsoft через сервер.',
checkpoint_date: 'Date', // TODO: translate
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
@@ -6429,6 +6437,8 @@ const LOCALES = {
settings_desc_tts_voice: 'Seleccionar voz para síntesis de voz',
settings_label_tts_rate: 'Velocidad de voz',
settings_label_tts_pitch: 'Tono de voz',
+ settings_label_tts_engine: 'Motor TTS',
+ settings_desc_tts_engine: 'Elija el motor de voz. Edge TTS utiliza voces neuronales de Microsoft a través del servidor.',
checkpoint_date: 'Date', // TODO: translate
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate
@@ -7688,6 +7698,8 @@ const LOCALES = {
settings_desc_tts_voice: 'Stimme für Sprachsynthese auswählen',
settings_label_tts_rate: 'Sprechgeschwindigkeit',
settings_label_tts_pitch: 'Tonhöhe',
+ settings_label_tts_engine: 'TTS-Engine',
+ settings_desc_tts_engine: 'Sprach-Engine auswählen. Edge TTS verwendet Microsoft Neural Voices über den Server.',
checkpoint_date: 'Date', // TODO: translate
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
@@ -8942,6 +8954,8 @@ const LOCALES = {
settings_desc_tts_voice: '选择语音合成声音',
settings_label_tts_rate: '语速',
settings_label_tts_pitch: '音调',
+ settings_label_tts_engine: 'TTS 引擎',
+ settings_desc_tts_engine: '选择语音引擎。Edge TTS 通过服务器使用微软神经语音。',
checkpoint_date: '日期',
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
checkpoint_diff_no_changes: '此检查点与当前工作区之间无差异。',
@@ -10271,6 +10285,8 @@ const LOCALES = {
settings_desc_tts_voice: '選擇語音合成聲音',
settings_label_tts_rate: '語速',
settings_label_tts_pitch: '音調',
+ settings_label_tts_engine: 'TTS 引擎',
+ settings_desc_tts_engine: '选择语音引擎。Edge TTS 通过服务器使用微软神经语音。',
checkpoint_date: 'Date', // TODO: translate
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
@@ -11414,6 +11430,8 @@ const LOCALES = {
settings_desc_tts_voice: 'Selecionar voz para síntese de voz',
settings_label_tts_rate: 'Velocidade da fala',
settings_label_tts_pitch: 'Tom da fala',
+ settings_label_tts_engine: 'Motor TTS',
+ settings_desc_tts_engine: 'Escolha o mecanismo de fala. Edge TTS usa vozes neurais da Microsoft através do servidor.',
checkpoint_date: 'Date', // TODO: translate
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate
@@ -12720,6 +12738,8 @@ const LOCALES = {
settings_desc_tts_voice: '음성 합성 음성 선택',
settings_label_tts_rate: '말 속도',
settings_label_tts_pitch: '말 톤',
+ settings_label_tts_engine: 'TTS 엔진',
+ settings_desc_tts_engine: '음성 엔진을 선택하세요. Edge TTS는 서버를 통해 Microsoft 신경 음성을 사용합니다.',
checkpoint_date: 'Date', // TODO: translate
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`, // TODO: translate
checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.', // TODO: translate
@@ -13485,6 +13505,8 @@ const LOCALES = {
settings_desc_tts_voice: 'Voix préférée. Rempli à partir des voix disponibles dans votre navigateur.',
settings_label_tts_rate: 'Taux de parole',
settings_label_tts_pitch: 'Emplacement du discours',
+ settings_label_tts_engine: 'Moteur TTS',
+ settings_desc_tts_engine: 'Choisissez le moteur de synthèse vocale. Edge TTS utilise les voix neuronales Microsoft via le serveur.',
settings_label_notifications: 'Notifications du navigateur',
settings_desc_notifications: 'Afficher une notification système lorsqu\'une réponse est terminée alors que l\'application est en arrière-plan.',
settings_desc_token_usage: 'Affiche le nombre de jetons d’entrée/sortie sous chaque réponse de l’assistant. Également basculé avec /usage.',
@@ -15294,6 +15316,8 @@ const LOCALES = {
settings_desc_tts_voice: 'Ses sentezi sesini seçin',
settings_label_tts_rate: 'Konuşma hızı',
settings_label_tts_pitch: 'Konuşma perdesi',
+ settings_label_tts_engine: 'TTS Motoru',
+ settings_desc_tts_engine: 'Ses motorunu seçin. Edge TTS, sunucu üzerinden Microsoft sinirsel seslerini kullanır.',
checkpoint_date: 'Tarih',
checkpoint_diff_files_changed: (n) => `${n} dosya değişti`,
checkpoint_diff_no_changes: 'Bu kontrol noktası ile mevcut çalışma alanı arasında fark bulunamadı.',
diff --git a/static/index.html b/static/index.html
index 6d81ee1a..c7ba2a37 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1070,7 +1070,7 @@
Record and send the original audio file to the agent instead of converting it to text first. The agent can then transcribe it or process the raw audio (emotion, background noise, custom STT). Like Telegram's voice message behavior.
-
+
Choose speech engine. Edge TTS uses Microsoft neural voices via the server.